summaryrefslogtreecommitdiffstats
path: root/browser/components/payments
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/payments')
-rw-r--r--browser/components/payments/.eslintrc.js84
-rw-r--r--browser/components/payments/PaymentUIService.jsm352
-rw-r--r--browser/components/payments/components.conf14
-rw-r--r--browser/components/payments/content/paymentDialogFrameScript.js181
-rw-r--r--browser/components/payments/content/paymentDialogWrapper.js931
-rw-r--r--browser/components/payments/docs/index.rst111
-rw-r--r--browser/components/payments/jar.mn26
-rw-r--r--browser/components/payments/moz.build36
-rw-r--r--browser/components/payments/res/PaymentsStore.js97
-rw-r--r--browser/components/payments/res/components/accepted-cards.css108
-rw-r--r--browser/components/payments/res/components/accepted-cards.js75
-rw-r--r--browser/components/payments/res/components/address-option.css29
-rw-r--r--browser/components/payments/res/components/address-option.js159
-rw-r--r--browser/components/payments/res/components/basic-card-option.css40
-rw-r--r--browser/components/payments/res/components/basic-card-option.js89
-rw-r--r--browser/components/payments/res/components/card-icon.svg9
-rw-r--r--browser/components/payments/res/components/csc-input.js112
-rw-r--r--browser/components/payments/res/components/currency-amount.js63
-rw-r--r--browser/components/payments/res/components/labelled-checkbox.js59
-rw-r--r--browser/components/payments/res/components/payment-details-item.css12
-rw-r--r--browser/components/payments/res/components/payment-details-item.js47
-rw-r--r--browser/components/payments/res/components/payment-request-page.js36
-rw-r--r--browser/components/payments/res/components/rich-option.js26
-rw-r--r--browser/components/payments/res/components/rich-select.css58
-rw-r--r--browser/components/payments/res/components/rich-select.js104
-rw-r--r--browser/components/payments/res/components/shipping-option.css16
-rw-r--r--browser/components/payments/res/components/shipping-option.js65
-rw-r--r--browser/components/payments/res/containers/address-form.css55
-rw-r--r--browser/components/payments/res/containers/address-form.js447
-rw-r--r--browser/components/payments/res/containers/address-picker.js282
-rw-r--r--browser/components/payments/res/containers/basic-card-form.css43
-rw-r--r--browser/components/payments/res/containers/basic-card-form.js507
-rw-r--r--browser/components/payments/res/containers/billing-address-picker.js33
-rw-r--r--browser/components/payments/res/containers/completion-error-page.js112
-rw-r--r--browser/components/payments/res/containers/cvv-hint-image-back.svg27
-rw-r--r--browser/components/payments/res/containers/cvv-hint-image-front.svg25
-rw-r--r--browser/components/payments/res/containers/error-page.css42
-rw-r--r--browser/components/payments/res/containers/order-details.css55
-rw-r--r--browser/components/payments/res/containers/order-details.js143
-rw-r--r--browser/components/payments/res/containers/payment-dialog.js593
-rw-r--r--browser/components/payments/res/containers/payment-method-picker.js199
-rw-r--r--browser/components/payments/res/containers/rich-picker.css83
-rw-r--r--browser/components/payments/res/containers/rich-picker.js114
-rw-r--r--browser/components/payments/res/containers/shipping-option-picker.js72
-rw-r--r--browser/components/payments/res/containers/timeout.svg84
-rw-r--r--browser/components/payments/res/containers/warning.svg32
-rw-r--r--browser/components/payments/res/debugging.css35
-rw-r--r--browser/components/payments/res/debugging.html75
-rw-r--r--browser/components/payments/res/debugging.js664
-rw-r--r--browser/components/payments/res/mixins/HandleEventMixin.js28
-rw-r--r--browser/components/payments/res/mixins/ObservedPropertiesMixin.js71
-rw-r--r--browser/components/payments/res/mixins/PaymentStateSubscriberMixin.js112
-rw-r--r--browser/components/payments/res/paymentRequest.css265
-rw-r--r--browser/components/payments/res/paymentRequest.js356
-rw-r--r--browser/components/payments/res/paymentRequest.xhtml303
-rw-r--r--browser/components/payments/res/unprivileged-fallbacks.js159
-rw-r--r--browser/components/payments/server.py23
-rw-r--r--browser/components/payments/test/PaymentTestUtils.jsm612
-rw-r--r--browser/components/payments/test/browser/blank_page.html10
-rw-r--r--browser/components/payments/test/browser/browser.ini34
-rw-r--r--browser/components/payments/test/browser/browser_address_edit.js1029
-rw-r--r--browser/components/payments/test/browser/browser_address_edit_hidden_fields.js477
-rw-r--r--browser/components/payments/test/browser/browser_card_edit.js1227
-rw-r--r--browser/components/payments/test/browser/browser_change_shipping.js732
-rw-r--r--browser/components/payments/test/browser/browser_dropdowns.js82
-rw-r--r--browser/components/payments/test/browser/browser_host_name.js50
-rw-r--r--browser/components/payments/test/browser/browser_onboarding_wizard.js859
-rw-r--r--browser/components/payments/test/browser/browser_openPreferences.js93
-rw-r--r--browser/components/payments/test/browser/browser_payerRequestedFields.js154
-rw-r--r--browser/components/payments/test/browser/browser_payment_completion.js211
-rw-r--r--browser/components/payments/test/browser/browser_profile_storage.js303
-rw-r--r--browser/components/payments/test/browser/browser_request_serialization.js250
-rw-r--r--browser/components/payments/test/browser/browser_request_shipping.js121
-rw-r--r--browser/components/payments/test/browser/browser_retry.js169
-rw-r--r--browser/components/payments/test/browser/browser_retry_fieldErrors.js882
-rw-r--r--browser/components/payments/test/browser/browser_shippingaddresschange_error.js446
-rw-r--r--browser/components/payments/test/browser/browser_show_dialog.js400
-rw-r--r--browser/components/payments/test/browser/browser_tab_modal.js300
-rw-r--r--browser/components/payments/test/browser/browser_total.js94
-rw-r--r--browser/components/payments/test/browser/head.js880
-rw-r--r--browser/components/payments/test/mochitest/formautofill/mochitest.ini10
-rw-r--r--browser/components/payments/test/mochitest/formautofill/test_editCreditCard.html34
-rw-r--r--browser/components/payments/test/mochitest/mochitest.ini37
-rw-r--r--browser/components/payments/test/mochitest/payments_common.js154
-rw-r--r--browser/components/payments/test/mochitest/test_ObservedPropertiesMixin.html116
-rw-r--r--browser/components/payments/test/mochitest/test_PaymentStateSubscriberMixin.html79
-rw-r--r--browser/components/payments/test/mochitest/test_PaymentsStore.html168
-rw-r--r--browser/components/payments/test/mochitest/test_accepted_cards.html111
-rw-r--r--browser/components/payments/test/mochitest/test_address_form.html955
-rw-r--r--browser/components/payments/test/mochitest/test_address_option.html177
-rw-r--r--browser/components/payments/test/mochitest/test_address_picker.html278
-rw-r--r--browser/components/payments/test/mochitest/test_basic_card_form.html623
-rw-r--r--browser/components/payments/test/mochitest/test_basic_card_option.html96
-rw-r--r--browser/components/payments/test/mochitest/test_billing_address_picker.html132
-rw-r--r--browser/components/payments/test/mochitest/test_completion_error_page.html88
-rw-r--r--browser/components/payments/test/mochitest/test_currency_amount.html160
-rw-r--r--browser/components/payments/test/mochitest/test_labelled_checkbox.html71
-rw-r--r--browser/components/payments/test/mochitest/test_order_details.html215
-rw-r--r--browser/components/payments/test/mochitest/test_payer_address_picker.html323
-rw-r--r--browser/components/payments/test/mochitest/test_payment_details_item.html65
-rw-r--r--browser/components/payments/test/mochitest/test_payment_dialog.html360
-rw-r--r--browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html252
-rw-r--r--browser/components/payments/test/mochitest/test_payment_method_picker.html279
-rw-r--r--browser/components/payments/test/mochitest/test_rich_select.html150
-rw-r--r--browser/components/payments/test/mochitest/test_shipping_option_picker.html180
-rw-r--r--browser/components/payments/test/unit/head.js2
-rw-r--r--browser/components/payments/test/unit/test_response_creation.js149
-rw-r--r--browser/components/payments/test/unit/xpcshell.ini6
108 files changed, 22663 insertions, 0 deletions
diff --git a/browser/components/payments/.eslintrc.js b/browser/components/payments/.eslintrc.js
new file mode 100644
index 0000000000..2497132927
--- /dev/null
+++ b/browser/components/payments/.eslintrc.js
@@ -0,0 +1,84 @@
+/* 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/. */
+
+"use strict";
+
+module.exports = {
+ overrides: [
+ {
+ files: [
+ "res/components/*.js",
+ "res/containers/*.js",
+ "res/mixins/*.js",
+ "res/paymentRequest.js",
+ "res/PaymentsStore.js",
+ "test/mochitest/test_*.html",
+ ],
+ parserOptions: {
+ sourceType: "module",
+ },
+ },
+ {
+ files: "test/unit/head.js",
+ rules: {
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "local",
+ },
+ ],
+ },
+ },
+ ],
+ rules: {
+ "mozilla/var-only-at-top-level": "error",
+
+ "block-scoped-var": "error",
+ complexity: [
+ "error",
+ {
+ max: 20,
+ },
+ ],
+ "max-nested-callbacks": ["error", 4],
+ "no-console": ["error", { allow: ["error"] }],
+ "no-fallthrough": "error",
+ "no-multi-str": "error",
+ "no-proto": "error",
+ "no-unused-expressions": "error",
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "all",
+ },
+ ],
+ "no-use-before-define": [
+ "error",
+ {
+ functions: false,
+ },
+ ],
+ radix: "error",
+ "valid-jsdoc": [
+ "error",
+ {
+ prefer: {
+ return: "returns",
+ },
+ preferType: {
+ Boolean: "boolean",
+ Number: "number",
+ String: "string",
+ bool: "boolean",
+ },
+ requireParamDescription: false,
+ requireReturn: false,
+ requireReturnDescription: false,
+ },
+ ],
+ yoda: "error",
+ },
+};
diff --git a/browser/components/payments/PaymentUIService.jsm b/browser/components/payments/PaymentUIService.jsm
new file mode 100644
index 0000000000..a0b60d8c89
--- /dev/null
+++ b/browser/components/payments/PaymentUIService.jsm
@@ -0,0 +1,352 @@
+/* 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/. */
+
+/**
+ * Singleton service acting as glue between the DOM APIs and the payment dialog UI.
+ *
+ * Communication from the DOM to the UI happens via the nsIPaymentUIService interface.
+ * The UI talks to the DOM code via the nsIPaymentRequestService interface.
+ * PaymentUIService is started by the DOM code lazily.
+ *
+ * For now the UI is shown in a native dialog but that is likely to change.
+ * Tests should try to avoid relying on that implementation detail.
+ */
+
+"use strict";
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserWindowTracker",
+ "resource:///modules/BrowserWindowTracker.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "paymentSrv",
+ "@mozilla.org/dom/payments/payment-request-service;1",
+ "nsIPaymentRequestService"
+);
+
+function PaymentUIService() {
+ this.wrappedJSObject = this;
+ XPCOMUtils.defineLazyGetter(this, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.import(
+ "resource://gre/modules/Console.jsm"
+ );
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.payments.loglevel",
+ prefix: "Payment UI Service",
+ });
+ });
+ this.log.debug("constructor");
+}
+
+PaymentUIService.prototype = {
+ classID: Components.ID("{01f8bd55-9017-438b-85ec-7c15d2b35cdc}"),
+ QueryInterface: ChromeUtils.generateQI(["nsIPaymentUIService"]),
+
+ // nsIPaymentUIService implementation:
+
+ showPayment(requestId) {
+ this.log.debug("showPayment:", requestId);
+ let request = paymentSrv.getPaymentRequestById(requestId);
+ let merchantBrowser = this.findBrowserByOuterWindowId(
+ request.topOuterWindowId
+ );
+ let chromeWindow = merchantBrowser.ownerGlobal;
+ let { gBrowser } = chromeWindow;
+ let browserContainer = gBrowser.getBrowserContainer(merchantBrowser);
+ let container = chromeWindow.document.createElementNS(XHTML_NS, "div");
+ container.dataset.requestId = requestId;
+ container.classList.add("paymentDialogContainer");
+ container.hidden = true;
+ let paymentsBrowser = this._createPaymentFrame(
+ chromeWindow.document,
+ requestId
+ );
+
+ let pdwGlobal = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://payments/content/paymentDialogWrapper.js",
+ pdwGlobal
+ );
+
+ paymentsBrowser.paymentDialogWrapper = pdwGlobal.paymentDialogWrapper;
+
+ // Create an <html:div> wrapper to absolutely position the <xul:browser>
+ // because XUL elements don't support position:absolute.
+ let absDiv = chromeWindow.document.createElementNS(XHTML_NS, "div");
+ container.appendChild(absDiv);
+
+ // append the frame to start the loading
+ absDiv.appendChild(paymentsBrowser);
+ browserContainer.prepend(container);
+
+ // Initialize the wrapper once the <browser> is connected.
+ paymentsBrowser.paymentDialogWrapper.init(requestId, paymentsBrowser);
+
+ this._attachBrowserEventListeners(merchantBrowser);
+
+ // Only show the frame and change the UI when the dialog is ready to show.
+ paymentsBrowser.addEventListener(
+ "tabmodaldialogready",
+ function readyToShow() {
+ if (!container) {
+ // The dialog was closed by the DOM code before it was ready to be shown.
+ return;
+ }
+ container.hidden = false;
+ this._showDialog(merchantBrowser);
+ }.bind(this),
+ {
+ once: true,
+ }
+ );
+ },
+
+ abortPayment(requestId) {
+ this.log.debug("abortPayment:", requestId);
+ let abortResponse = Cc[
+ "@mozilla.org/dom/payments/payment-abort-action-response;1"
+ ].createInstance(Ci.nsIPaymentAbortActionResponse);
+ let found = this.closeDialog(requestId);
+
+ // if `win` is falsy, then we haven't found the dialog, so the abort fails
+ // otherwise, the abort is successful
+ let response = found
+ ? Ci.nsIPaymentActionResponse.ABORT_SUCCEEDED
+ : Ci.nsIPaymentActionResponse.ABORT_FAILED;
+
+ abortResponse.init(requestId, response);
+ paymentSrv.respondPayment(abortResponse);
+ },
+
+ completePayment(requestId) {
+ // completeStatus should be one of "timeout", "success", "fail", ""
+ let { completeStatus } = paymentSrv.getPaymentRequestById(requestId);
+ this.log.debug(
+ `completePayment: requestId: ${requestId}, completeStatus: ${completeStatus}`
+ );
+
+ let closed;
+ switch (completeStatus) {
+ case "fail":
+ case "timeout":
+ break;
+ default:
+ closed = this.closeDialog(requestId);
+ break;
+ }
+
+ let paymentFrame;
+ if (!closed) {
+ // We need to call findDialog before we respond below as getPaymentRequestById
+ // may fail due to the request being removed upon completion.
+ paymentFrame = this.findDialog(requestId).paymentFrame;
+ if (!paymentFrame) {
+ this.log.error("completePayment: no dialog found");
+ return;
+ }
+ }
+
+ let responseCode = closed
+ ? Ci.nsIPaymentActionResponse.COMPLETE_SUCCEEDED
+ : Ci.nsIPaymentActionResponse.COMPLETE_FAILED;
+ let completeResponse = Cc[
+ "@mozilla.org/dom/payments/payment-complete-action-response;1"
+ ].createInstance(Ci.nsIPaymentCompleteActionResponse);
+ completeResponse.init(requestId, responseCode);
+ paymentSrv.respondPayment(
+ completeResponse.QueryInterface(Ci.nsIPaymentActionResponse)
+ );
+
+ if (!closed) {
+ paymentFrame.paymentDialogWrapper.updateRequest();
+ }
+ },
+
+ updatePayment(requestId) {
+ let { paymentFrame } = this.findDialog(requestId);
+ this.log.debug("updatePayment:", requestId);
+ if (!paymentFrame) {
+ this.log.error("updatePayment: no dialog found");
+ return;
+ }
+ paymentFrame.paymentDialogWrapper.updateRequest();
+ },
+
+ closePayment(requestId) {
+ this.closeDialog(requestId);
+ },
+
+ // other helper methods
+
+ _createPaymentFrame(doc, requestId) {
+ let frame = doc.createXULElement("browser");
+ frame.classList.add("paymentDialogContainerFrame");
+ frame.setAttribute("type", "content");
+ frame.setAttribute("remote", "true");
+ frame.setAttribute("disablehistory", "true");
+ frame.setAttribute("nodefaultsrc", "true");
+ frame.setAttribute("transparent", "true");
+ frame.setAttribute("selectmenulist", "ContentSelectDropdown");
+ frame.setAttribute("autocompletepopup", "PopupAutoComplete");
+ return frame;
+ },
+
+ _attachBrowserEventListeners(merchantBrowser) {
+ merchantBrowser.addEventListener("SwapDocShells", this);
+ },
+
+ _showDialog(merchantBrowser) {
+ let chromeWindow = merchantBrowser.ownerGlobal;
+ // Prevent focusing or interacting with the <browser>.
+ merchantBrowser.setAttribute("tabmodalPromptShowing", "true");
+
+ // Darken the merchant content area.
+ let tabModalBackground = chromeWindow.document.createXULElement("box");
+ tabModalBackground.classList.add(
+ "tabModalBackground",
+ "paymentDialogBackground"
+ );
+ // Insert the same way as <tabmodalprompt>.
+ merchantBrowser.parentNode.insertBefore(
+ tabModalBackground,
+ merchantBrowser.nextElementSibling
+ );
+ },
+
+ /**
+ * @param {string} requestId - Payment Request ID of the dialog to close.
+ * @returns {boolean} whether the specified dialog was closed.
+ */
+ closeDialog(requestId) {
+ let { browser, dialogContainer, paymentFrame } = this.findDialog(requestId);
+ if (!dialogContainer) {
+ return false;
+ }
+ this.log.debug(`closing: ${requestId}`);
+ paymentFrame.paymentDialogWrapper.uninit();
+ dialogContainer.remove();
+ browser.removeEventListener("SwapDocShells", this);
+
+ if (!dialogContainer.hidden) {
+ // If the container is no longer hidden then the background was added after
+ // `tabmodaldialogready` so remove it.
+ browser.parentElement.querySelector(".paymentDialogBackground").remove();
+
+ if (
+ !browser.tabModalPromptBox ||
+ !browser.tabModalPromptBox.listPrompts().length
+ ) {
+ browser.removeAttribute("tabmodalPromptShowing");
+ }
+ }
+ return true;
+ },
+
+ getDialogContainerForMerchantBrowser(merchantBrowser) {
+ return merchantBrowser.ownerGlobal.gBrowser
+ .getBrowserContainer(merchantBrowser)
+ .querySelector(".paymentDialogContainer");
+ },
+
+ findDialog(requestId) {
+ for (let win of BrowserWindowTracker.orderedWindows) {
+ for (let dialogContainer of win.document.querySelectorAll(
+ ".paymentDialogContainer"
+ )) {
+ if (dialogContainer.dataset.requestId == requestId) {
+ return {
+ dialogContainer,
+ paymentFrame: dialogContainer.querySelector(
+ ".paymentDialogContainerFrame"
+ ),
+ browser: dialogContainer.parentElement.querySelector(
+ ".browserStack > browser"
+ ),
+ };
+ }
+ }
+ }
+ return {};
+ },
+
+ findBrowserByOuterWindowId(outerWindowId) {
+ for (let win of BrowserWindowTracker.orderedWindows) {
+ let browser = win.gBrowser.getBrowserForOuterWindowID(outerWindowId);
+ if (!browser) {
+ continue;
+ }
+ return browser;
+ }
+
+ this.log.error(
+ "findBrowserByOuterWindowId: No browser found for outerWindowId:",
+ outerWindowId
+ );
+ return null;
+ },
+
+ _moveDialogToNewBrowser(oldBrowser, newBrowser) {
+ // Re-attach event listeners to the new browser.
+ newBrowser.addEventListener("SwapDocShells", this);
+
+ let dialogContainer = this.getDialogContainerForMerchantBrowser(oldBrowser);
+ let newBrowserContainer = newBrowser.ownerGlobal.gBrowser.getBrowserContainer(
+ newBrowser
+ );
+
+ // Clone the container tree
+ let newDialogContainer = newBrowserContainer.ownerDocument.importNode(
+ dialogContainer,
+ true
+ );
+
+ let oldFrame = dialogContainer.querySelector(
+ ".paymentDialogContainerFrame"
+ );
+ let newFrame = newDialogContainer.querySelector(
+ ".paymentDialogContainerFrame"
+ );
+
+ // We need a document to be synchronously loaded in order to do the swap and
+ // there's no point in wasting resources loading a dialog we're going to replace.
+ newFrame.setAttribute("src", "about:blank");
+ newFrame.setAttribute("nodefaultsrc", "true");
+
+ newBrowserContainer.prepend(newDialogContainer);
+
+ // Force the <browser> to be created so that it'll have a document loaded and frame created.
+ // See `ourChildDocument` and `ourFrame` checks in nsFrameLoader::SwapWithOtherLoader.
+ /* eslint-disable-next-line no-unused-expressions */
+ newFrame.clientTop;
+
+ // Swap the frameLoaders to preserve the frame state
+ newFrame.swapFrameLoaders(oldFrame);
+ newFrame.paymentDialogWrapper = oldFrame.paymentDialogWrapper;
+ newFrame.paymentDialogWrapper.changeAttachedFrame(newFrame);
+ dialogContainer.remove();
+
+ this._showDialog(newBrowser);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "SwapDocShells": {
+ this._moveDialogToNewBrowser(event.target, event.detail);
+ break;
+ }
+ }
+ },
+};
+
+var EXPORTED_SYMBOLS = ["PaymentUIService"];
diff --git a/browser/components/payments/components.conf b/browser/components/payments/components.conf
new file mode 100644
index 0000000000..d6a4eb1efc
--- /dev/null
+++ b/browser/components/payments/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{01f8bd55-9017-438b-85ec-7c15d2b35cdc}',
+ 'contract_ids': ['@mozilla.org/dom/payments/payment-ui-service;1'],
+ 'jsm': 'resource:///modules/PaymentUIService.jsm',
+ 'constructor': 'PaymentUIService',
+ },
+]
diff --git a/browser/components/payments/content/paymentDialogFrameScript.js b/browser/components/payments/content/paymentDialogFrameScript.js
new file mode 100644
index 0000000000..4397ba2f85
--- /dev/null
+++ b/browser/components/payments/content/paymentDialogFrameScript.js
@@ -0,0 +1,181 @@
+/* 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/. */
+
+/**
+ * This frame script only exists to mediate communications between the
+ * unprivileged frame in a content process and the privileged dialog wrapper
+ * in the UI process on the main thread.
+ *
+ * `paymentChromeToContent` messages from the privileged wrapper are converted
+ * into DOM events of the same name.
+ * `paymentContentToChrome` custom DOM events from the unprivileged frame are
+ * converted into messages of the same name.
+ *
+ * Business logic should stay out of this shim.
+ */
+
+"use strict";
+
+/* eslint-env mozilla/frame-script */
+/* global Services */
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "FormAutofill",
+ "resource://formautofill/FormAutofill.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "FormAutofillUtils",
+ "resource://formautofill/FormAutofillUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "AppConstants",
+ "resource://gre/modules/AppConstants.jsm"
+);
+
+const SAVE_CREDITCARD_DEFAULT_PREF = "dom.payments.defaults.saveCreditCard";
+const SAVE_ADDRESS_DEFAULT_PREF = "dom.payments.defaults.saveAddress";
+
+let PaymentFrameScript = {
+ init() {
+ XPCOMUtils.defineLazyGetter(this, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.import(
+ "resource://gre/modules/Console.jsm"
+ );
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.payments.loglevel",
+ prefix: "paymentDialogFrameScript",
+ });
+ });
+
+ addEventListener("paymentContentToChrome", this, false, true);
+
+ addMessageListener("paymentChromeToContent", this);
+ },
+
+ handleEvent(event) {
+ this.sendToChrome(event);
+ },
+
+ receiveMessage({ data: { messageType, data } }) {
+ this.sendToContent(messageType, data);
+ },
+
+ setupContentConsole() {
+ let privilegedLogger = content.window.console.createInstance({
+ maxLogLevelPref: "dom.payments.loglevel",
+ prefix: "paymentDialogContent",
+ });
+
+ let contentLogObject = Cu.waiveXrays(content).log;
+ for (let name of ["error", "warn", "info", "debug"]) {
+ Cu.exportFunction(
+ privilegedLogger[name].bind(privilegedLogger),
+ contentLogObject,
+ {
+ defineAs: name,
+ }
+ );
+ }
+ },
+
+ /**
+ * Expose privileged utility functions to the unprivileged page.
+ */
+ exposeUtilityFunctions() {
+ let waivedContent = Cu.waiveXrays(content);
+ let PaymentDialogUtils = {
+ DEFAULT_REGION: FormAutofill.DEFAULT_REGION,
+ countries: FormAutofill.countries,
+
+ getAddressLabel(address, addressFields = null) {
+ return FormAutofillUtils.getAddressLabel(address, addressFields);
+ },
+
+ getCreditCardNetworks() {
+ let networks = FormAutofillUtils.getCreditCardNetworks();
+ return Cu.cloneInto(networks, waivedContent);
+ },
+
+ isCCNumber(value) {
+ return FormAutofillUtils.isCCNumber(value);
+ },
+
+ getFormFormat(country) {
+ let format = FormAutofillUtils.getFormFormat(country);
+ return Cu.cloneInto(format, waivedContent);
+ },
+
+ findAddressSelectOption(selectEl, address, fieldName) {
+ return FormAutofillUtils.findAddressSelectOption(
+ selectEl,
+ address,
+ fieldName
+ );
+ },
+
+ getDefaultPreferences() {
+ let prefValues = Cu.cloneInto(
+ {
+ saveCreditCardDefaultChecked: Services.prefs.getBoolPref(
+ SAVE_CREDITCARD_DEFAULT_PREF,
+ false
+ ),
+ saveAddressDefaultChecked: Services.prefs.getBoolPref(
+ SAVE_ADDRESS_DEFAULT_PREF,
+ false
+ ),
+ },
+ waivedContent
+ );
+ return Cu.cloneInto(prefValues, waivedContent);
+ },
+
+ isOfficialBranding() {
+ return AppConstants.MOZILLA_OFFICIAL;
+ },
+ };
+ waivedContent.PaymentDialogUtils = Cu.cloneInto(
+ PaymentDialogUtils,
+ waivedContent,
+ {
+ cloneFunctions: true,
+ }
+ );
+ },
+
+ sendToChrome({ detail }) {
+ let { messageType } = detail;
+ if (messageType == "initializeRequest") {
+ this.setupContentConsole();
+ this.exposeUtilityFunctions();
+ }
+ this.log.debug("sendToChrome:", messageType, detail);
+ this.sendMessageToChrome(messageType, detail);
+ },
+
+ sendToContent(messageType, detail = {}) {
+ this.log.debug("sendToContent", messageType, detail);
+ let response = Object.assign({ messageType }, detail);
+ let event = new content.CustomEvent("paymentChromeToContent", {
+ detail: Cu.cloneInto(response, content),
+ });
+ content.dispatchEvent(event);
+ },
+
+ sendMessageToChrome(messageType, data = {}) {
+ sendAsyncMessage(
+ "paymentContentToChrome",
+ Object.assign(data, { messageType })
+ );
+ },
+};
+
+PaymentFrameScript.init();
diff --git a/browser/components/payments/content/paymentDialogWrapper.js b/browser/components/payments/content/paymentDialogWrapper.js
new file mode 100644
index 0000000000..c72068961d
--- /dev/null
+++ b/browser/components/payments/content/paymentDialogWrapper.js
@@ -0,0 +1,931 @@
+/* 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/. */
+
+/**
+ * Runs in the privileged outer dialog. Each dialog loads this script in its
+ * own scope.
+ */
+
+/* exported paymentDialogWrapper */
+
+"use strict";
+
+const paymentSrv = Cc[
+ "@mozilla.org/dom/payments/payment-request-service;1"
+].getService(Ci.nsIPaymentRequestService);
+
+const paymentUISrv = Cc[
+ "@mozilla.org/dom/payments/payment-ui-service;1"
+].getService(Ci.nsIPaymentUIService);
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserWindowTracker",
+ "resource:///modules/BrowserWindowTracker.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "FormAutofillUtils",
+ "resource://formautofill/FormAutofillUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "OSKeyStore",
+ "resource://gre/modules/OSKeyStore.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGetter(this, "formAutofillStorage", () => {
+ let storage;
+ try {
+ storage = ChromeUtils.import(
+ "resource://formautofill/FormAutofillStorage.jsm",
+ {}
+ ).formAutofillStorage;
+ storage.initialize();
+ } catch (ex) {
+ storage = null;
+ Cu.reportError(ex);
+ }
+
+ return storage;
+});
+
+XPCOMUtils.defineLazyGetter(this, "reauthPasswordPromptMessage", () => {
+ const brandShortName = FormAutofillUtils.brandBundle.GetStringFromName(
+ "brandShortName"
+ );
+ // The string name for Mac is changed because the value needed updating.
+ const platform = AppConstants.platform.replace("macosx", "macos");
+ return FormAutofillUtils.stringBundle.formatStringFromName(
+ `useCreditCardPasswordPrompt.${platform}`,
+ [brandShortName]
+ );
+});
+
+/**
+ * Temporary/transient storage for address and credit card records
+ *
+ * Implements a subset of the FormAutofillStorage collection class interface, and delegates to
+ * those classes for some utility methods
+ */
+class TempCollection {
+ constructor(type, data = {}) {
+ /**
+ * The name of the collection. e.g. 'addresses' or 'creditCards'
+ * Used to access methods from the FormAutofillStorage collections
+ */
+ this._type = type;
+ this._data = data;
+ }
+
+ get _formAutofillCollection() {
+ // lazy getter for the formAutofill collection - to resolve on first access
+ Object.defineProperty(this, "_formAutofillCollection", {
+ value: formAutofillStorage[this._type],
+ writable: false,
+ configurable: true,
+ });
+ return this._formAutofillCollection;
+ }
+
+ get(guid) {
+ return this._data[guid];
+ }
+
+ async update(guid, record, preserveOldProperties) {
+ let recordToSave = Object.assign(
+ preserveOldProperties ? this._data[guid] : {},
+ record
+ );
+ await this._formAutofillCollection.computeFields(recordToSave);
+ return (this._data[guid] = recordToSave);
+ }
+
+ async add(record) {
+ let guid = "temp-" + Math.abs((Math.random() * 0xffffffff) | 0);
+ let timeLastModified = Date.now();
+ let recordToSave = Object.assign({ guid, timeLastModified }, record);
+ await this._formAutofillCollection.computeFields(recordToSave);
+ this._data[guid] = recordToSave;
+ return guid;
+ }
+
+ getAll() {
+ return this._data;
+ }
+}
+
+var paymentDialogWrapper = {
+ componentsLoaded: new Map(),
+ frameWeakRef: null,
+ mm: null,
+ request: null,
+ temporaryStore: null,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ /**
+ * @param {string} guid
+ * @returns {object} containing only the requested payer values.
+ */
+ async _convertProfileAddressToPayerData(guid) {
+ let addressData =
+ this.temporaryStore.addresses.get(guid) ||
+ (await formAutofillStorage.addresses.get(guid));
+ if (!addressData) {
+ throw new Error(`Payer address not found: ${guid}`);
+ }
+
+ let {
+ requestPayerName,
+ requestPayerEmail,
+ requestPayerPhone,
+ } = this.request.paymentOptions;
+
+ let payerData = {
+ payerName: requestPayerName ? addressData.name : "",
+ payerEmail: requestPayerEmail ? addressData.email : "",
+ payerPhone: requestPayerPhone ? addressData.tel : "",
+ };
+
+ return payerData;
+ },
+
+ /**
+ * @param {string} guid
+ * @returns {nsIPaymentAddress}
+ */
+ async _convertProfileAddressToPaymentAddress(guid) {
+ let addressData =
+ this.temporaryStore.addresses.get(guid) ||
+ (await formAutofillStorage.addresses.get(guid));
+ if (!addressData) {
+ throw new Error(`Address not found: ${guid}`);
+ }
+
+ let address = this.createPaymentAddress({
+ addressLines: addressData["street-address"].split("\n"),
+ city: addressData["address-level2"],
+ country: addressData.country,
+ dependentLocality: addressData["address-level3"],
+ organization: addressData.organization,
+ phone: addressData.tel,
+ postalCode: addressData["postal-code"],
+ recipient: addressData.name,
+ region: addressData["address-level1"],
+ // TODO (bug 1474905), The regionCode will be available when bug 1474905 is fixed
+ // and the region text box is changed to a dropdown with the regionCode being the
+ // value of the option and the region being the label for the option.
+ // A regionCode should be either the empty string or one to three code points
+ // that represent a region as the code element of an [ISO3166-2] country subdivision
+ // name (i.e., the characters after the hyphen in an ISO3166-2 country subdivision
+ // code element, such as "CA" for the state of California in the USA, or "11" for
+ // the Lisbon district of Portugal).
+ regionCode: "",
+ });
+
+ return address;
+ },
+
+ /**
+ * @param {string} guid The GUID of the basic card record from storage.
+ * @param {string} cardSecurityCode The associated card security code (CVV/CCV/etc.)
+ * @throws If there is an error decrypting
+ * @returns {nsIBasicCardResponseData?} returns response data or null (if the
+ * master password dialog was cancelled);
+ */
+ async _convertProfileBasicCardToPaymentMethodData(guid, cardSecurityCode) {
+ let cardData =
+ this.temporaryStore.creditCards.get(guid) ||
+ (await formAutofillStorage.creditCards.get(guid));
+ if (!cardData) {
+ throw new Error(`Basic card not found in storage: ${guid}`);
+ }
+
+ let cardNumber;
+ try {
+ cardNumber = await OSKeyStore.decrypt(
+ cardData["cc-number-encrypted"],
+ reauthPasswordPromptMessage
+ );
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_ABORT) {
+ throw ex;
+ }
+ // User canceled master password entry
+ return null;
+ }
+
+ let billingAddressGUID = cardData.billingAddressGUID;
+ let billingAddress;
+ try {
+ billingAddress = await this._convertProfileAddressToPaymentAddress(
+ billingAddressGUID
+ );
+ } catch (ex) {
+ // The referenced address may not exist if it was deleted or hasn't yet synced to this profile
+ Cu.reportError(ex);
+ }
+ let methodData = this.createBasicCardResponseData({
+ cardholderName: cardData["cc-name"],
+ cardNumber,
+ expiryMonth: cardData["cc-exp-month"].toString().padStart(2, "0"),
+ expiryYear: cardData["cc-exp-year"].toString(),
+ cardSecurityCode,
+ billingAddress,
+ });
+
+ return methodData;
+ },
+
+ init(requestId, frame) {
+ if (!requestId || typeof requestId != "string") {
+ throw new Error("Invalid PaymentRequest ID");
+ }
+
+ // The Request object returned by the Payment Service is live and
+ // will automatically get updated if event.updateWith is used.
+ this.request = paymentSrv.getPaymentRequestById(requestId);
+
+ if (!this.request) {
+ throw new Error(`PaymentRequest not found: ${requestId}`);
+ }
+
+ this._attachToFrame(frame);
+ this.mm.loadFrameScript(
+ "chrome://payments/content/paymentDialogFrameScript.js",
+ true
+ );
+ // Until we have bug 1446164 and bug 1407418 we use form autofill's temporary
+ // shim for data-localization* attributes.
+ this.mm.loadFrameScript("chrome://formautofill/content/l10n.js", true);
+ frame.setAttribute("src", "resource://payments/paymentRequest.xhtml");
+
+ this.temporaryStore = {
+ addresses: new TempCollection("addresses"),
+ creditCards: new TempCollection("creditCards"),
+ };
+ },
+
+ uninit() {
+ try {
+ Services.obs.removeObserver(this, "message-manager-close");
+ Services.obs.removeObserver(this, "formautofill-storage-changed");
+ } catch (ex) {
+ // Observers may not have been added yet
+ }
+ },
+
+ /**
+ * Code here will be re-run at various times, e.g. initial show and
+ * when a tab is detached to a different window.
+ *
+ * Code that should only run once belongs in `init`.
+ * Code to only run upon detaching should be in `changeAttachedFrame`.
+ *
+ * @param {Element} frame
+ */
+ _attachToFrame(frame) {
+ this.frameWeakRef = Cu.getWeakReference(frame);
+ this.mm = frame.frameLoader.messageManager;
+ this.mm.addMessageListener("paymentContentToChrome", this);
+ Services.obs.addObserver(this, "message-manager-close", true);
+ },
+
+ /**
+ * Called only when a frame is changed from one to another.
+ *
+ * @param {Element} frame
+ */
+ changeAttachedFrame(frame) {
+ this.mm.removeMessageListener("paymentContentToChrome", this);
+ this._attachToFrame(frame);
+ // This isn't in `attachToFrame` because we only want to do it once we've sent records.
+ Services.obs.addObserver(this, "formautofill-storage-changed", true);
+ },
+
+ createShowResponse({
+ acceptStatus,
+ methodName = "",
+ methodData = null,
+ payerName = "",
+ payerEmail = "",
+ payerPhone = "",
+ }) {
+ let showResponse = this.createComponentInstance(
+ Ci.nsIPaymentShowActionResponse
+ );
+
+ showResponse.init(
+ this.request.requestId,
+ acceptStatus,
+ methodName,
+ methodData,
+ payerName,
+ payerEmail,
+ payerPhone
+ );
+ return showResponse;
+ },
+
+ createBasicCardResponseData({
+ cardholderName = "",
+ cardNumber,
+ expiryMonth = "",
+ expiryYear = "",
+ cardSecurityCode = "",
+ billingAddress = null,
+ }) {
+ const basicCardResponseData = Cc[
+ "@mozilla.org/dom/payments/basiccard-response-data;1"
+ ].createInstance(Ci.nsIBasicCardResponseData);
+ basicCardResponseData.initData(
+ cardholderName,
+ cardNumber,
+ expiryMonth,
+ expiryYear,
+ cardSecurityCode,
+ billingAddress
+ );
+ return basicCardResponseData;
+ },
+
+ createPaymentAddress({
+ addressLines = [],
+ city = "",
+ country = "",
+ dependentLocality = "",
+ organization = "",
+ postalCode = "",
+ phone = "",
+ recipient = "",
+ region = "",
+ regionCode = "",
+ sortingCode = "",
+ }) {
+ const paymentAddress = Cc[
+ "@mozilla.org/dom/payments/payment-address;1"
+ ].createInstance(Ci.nsIPaymentAddress);
+ const addressLine = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ for (let line of addressLines) {
+ const address = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ address.data = line;
+ addressLine.appendElement(address);
+ }
+ paymentAddress.init(
+ country,
+ addressLine,
+ region,
+ regionCode,
+ city,
+ dependentLocality,
+ postalCode,
+ sortingCode,
+ organization,
+ recipient,
+ phone
+ );
+ return paymentAddress;
+ },
+
+ createComponentInstance(componentInterface) {
+ let componentName;
+ switch (componentInterface) {
+ case Ci.nsIPaymentShowActionResponse: {
+ componentName =
+ "@mozilla.org/dom/payments/payment-show-action-response;1";
+ break;
+ }
+ case Ci.nsIGeneralResponseData: {
+ componentName = "@mozilla.org/dom/payments/general-response-data;1";
+ break;
+ }
+ }
+ let component = this.componentsLoaded.get(componentName);
+
+ if (!component) {
+ component = Cc[componentName];
+ this.componentsLoaded.set(componentName, component);
+ }
+
+ return component.createInstance(componentInterface);
+ },
+
+ async fetchSavedAddresses() {
+ let savedAddresses = {};
+ for (let address of await formAutofillStorage.addresses.getAll()) {
+ savedAddresses[address.guid] = address;
+ }
+ return savedAddresses;
+ },
+
+ async fetchSavedPaymentCards() {
+ let savedBasicCards = {};
+ for (let card of await formAutofillStorage.creditCards.getAll()) {
+ savedBasicCards[card.guid] = card;
+ // Filter out the encrypted card number since the dialog content is
+ // considered untrusted and runs in a content process.
+ delete card["cc-number-encrypted"];
+
+ // ensure each card has a methodName property
+ if (!card.methodName) {
+ card.methodName = "basic-card";
+ }
+ }
+ return savedBasicCards;
+ },
+
+ fetchTempPaymentCards() {
+ let creditCards = this.temporaryStore.creditCards.getAll();
+ for (let card of Object.values(creditCards)) {
+ // Ensure each card has a methodName property.
+ if (!card.methodName) {
+ card.methodName = "basic-card";
+ }
+ }
+ return creditCards;
+ },
+
+ async onAutofillStorageChange() {
+ let [savedAddresses, savedBasicCards] = await Promise.all([
+ this.fetchSavedAddresses(),
+ this.fetchSavedPaymentCards(),
+ ]);
+
+ this.sendMessageToContent("updateState", {
+ savedAddresses,
+ savedBasicCards,
+ });
+ },
+
+ sendMessageToContent(messageType, data = {}) {
+ this.mm.sendAsyncMessage("paymentChromeToContent", {
+ data,
+ messageType,
+ });
+ },
+
+ updateRequest() {
+ // There is no need to update this.request since the object is live
+ // and will automatically get updated if event.updateWith is used.
+ let requestSerialized = this._serializeRequest(this.request);
+
+ this.sendMessageToContent("updateState", {
+ request: requestSerialized,
+ });
+ },
+
+ /**
+ * Recursively convert and filter input to the subset of data types supported by JSON
+ *
+ * @param {*} value - any type of input to serialize
+ * @param {string?} name - name or key associated with this input.
+ * E.g. property name or array index.
+ * @returns {*} serialized deep copy of the value
+ */
+ _serializeRequest(value, name = null) {
+ // Primitives: String, Number, Boolean, null
+ let type = typeof value;
+ if (
+ value === null ||
+ type == "string" ||
+ type == "number" ||
+ type == "boolean"
+ ) {
+ return value;
+ }
+ if (name == "topLevelPrincipal") {
+ // Manually serialize the nsIPrincipal.
+ let displayHost = value.URI.displayHost;
+ return {
+ URI: {
+ displayHost,
+ },
+ };
+ }
+ if (type == "function" || type == "undefined") {
+ return undefined;
+ }
+ // Structures: nsIArray
+ if (value instanceof Ci.nsIArray) {
+ let iface;
+ let items = [];
+ switch (name) {
+ case "displayItems": // falls through
+ case "additionalDisplayItems":
+ iface = Ci.nsIPaymentItem;
+ break;
+ case "shippingOptions":
+ iface = Ci.nsIPaymentShippingOption;
+ break;
+ case "paymentMethods":
+ iface = Ci.nsIPaymentMethodData;
+ break;
+ case "modifiers":
+ iface = Ci.nsIPaymentDetailsModifier;
+ break;
+ }
+ if (!iface) {
+ throw new Error(
+ `No interface associated with the members of the ${name} nsIArray`
+ );
+ }
+ for (let i = 0; i < value.length; i++) {
+ let item = value.queryElementAt(i, iface);
+ let result = this._serializeRequest(item, i);
+ if (result !== undefined) {
+ items.push(result);
+ }
+ }
+ return items;
+ }
+ // Structures: Arrays
+ if (Array.isArray(value)) {
+ let items = value
+ .map(item => this._serializeRequest(item))
+ .filter(item => item !== undefined);
+ return items;
+ }
+ // Structures: Objects
+ let obj = {};
+ for (let [key, item] of Object.entries(value)) {
+ let result = this._serializeRequest(item, key);
+ if (result !== undefined) {
+ obj[key] = result;
+ }
+ }
+ return obj;
+ },
+
+ async initializeFrame() {
+ // We don't do this earlier as it's only necessary once this function sends
+ // the initial saved records.
+ Services.obs.addObserver(this, "formautofill-storage-changed", true);
+
+ let requestSerialized = this._serializeRequest(this.request);
+ let chromeWindow = this.frameWeakRef.get().ownerGlobal;
+ let isPrivate = PrivateBrowsingUtils.isWindowPrivate(chromeWindow);
+
+ let [savedAddresses, savedBasicCards] = await Promise.all([
+ this.fetchSavedAddresses(),
+ this.fetchSavedPaymentCards(),
+ ]);
+
+ this.sendMessageToContent("showPaymentRequest", {
+ request: requestSerialized,
+ savedAddresses,
+ tempAddresses: this.temporaryStore.addresses.getAll(),
+ savedBasicCards,
+ tempBasicCards: this.fetchTempPaymentCards(),
+ isPrivate,
+ });
+ },
+
+ debugFrame() {
+ // To avoid self-XSS-type attacks, ensure that Browser Chrome debugging is enabled.
+ if (!Services.prefs.getBoolPref("devtools.chrome.enabled", false)) {
+ Cu.reportError(
+ "devtools.chrome.enabled must be enabled to debug the frame"
+ );
+ return;
+ }
+ const { require } = ChromeUtils.import(
+ "resource://devtools/shared/Loader.jsm"
+ );
+ const {
+ gDevToolsBrowser,
+ } = require("devtools/client/framework/devtools-browser");
+ gDevToolsBrowser.openContentProcessToolbox({
+ selectedBrowser: this.frameWeakRef.get(),
+ });
+ },
+
+ onOpenPreferences() {
+ BrowserWindowTracker.getTopWindow().openPreferences(
+ "privacy-form-autofill"
+ );
+ },
+
+ onPaymentCancel() {
+ const showResponse = this.createShowResponse({
+ acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_REJECTED,
+ });
+
+ paymentSrv.respondPayment(showResponse);
+ paymentUISrv.closePayment(this.request.requestId);
+ },
+
+ async onPay({
+ selectedPayerAddressGUID: payerGUID,
+ selectedPaymentCardGUID: paymentCardGUID,
+ selectedPaymentCardSecurityCode: cardSecurityCode,
+ selectedShippingAddressGUID: shippingGUID,
+ }) {
+ let methodData;
+ try {
+ methodData = await this._convertProfileBasicCardToPaymentMethodData(
+ paymentCardGUID,
+ cardSecurityCode
+ );
+ } catch (ex) {
+ // TODO (Bug 1498403): Some kind of "credit card storage error" here, perhaps asking user
+ // to re-enter credit card # from management UI.
+ Cu.reportError(ex);
+ return;
+ }
+
+ if (!methodData) {
+ // TODO (Bug 1429265/Bug 1429205): Handle when a user hits cancel on the
+ // Master Password dialog.
+ Cu.reportError(
+ "Bug 1429265/Bug 1429205: User canceled master password entry"
+ );
+ return;
+ }
+
+ let payerName = "";
+ let payerEmail = "";
+ let payerPhone = "";
+ if (payerGUID) {
+ let payerData = await this._convertProfileAddressToPayerData(payerGUID);
+ payerName = payerData.payerName;
+ payerEmail = payerData.payerEmail;
+ payerPhone = payerData.payerPhone;
+ }
+
+ // Update the lastUsedTime for the payerAddress and paymentCard. Check if
+ // the record exists in formAutofillStorage because it may be temporary.
+ if (
+ shippingGUID &&
+ (await formAutofillStorage.addresses.get(shippingGUID))
+ ) {
+ formAutofillStorage.addresses.notifyUsed(shippingGUID);
+ }
+ if (payerGUID && (await formAutofillStorage.addresses.get(payerGUID))) {
+ formAutofillStorage.addresses.notifyUsed(payerGUID);
+ }
+ if (await formAutofillStorage.creditCards.get(paymentCardGUID)) {
+ formAutofillStorage.creditCards.notifyUsed(paymentCardGUID);
+ }
+
+ this.pay({
+ methodName: "basic-card",
+ methodData,
+ payerName,
+ payerEmail,
+ payerPhone,
+ });
+ },
+
+ pay({ payerName, payerEmail, payerPhone, methodName, methodData }) {
+ const showResponse = this.createShowResponse({
+ acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_ACCEPTED,
+ payerName,
+ payerEmail,
+ payerPhone,
+ methodName,
+ methodData,
+ });
+ paymentSrv.respondPayment(showResponse);
+ this.sendMessageToContent("responseSent");
+ },
+
+ async onChangePayerAddress({ payerAddressGUID }) {
+ if (payerAddressGUID) {
+ // If a payer address was de-selected e.g. the selected address was deleted, we'll
+ // just wait to send the address change when the payer address is eventually selected
+ // before clicking Pay since it's a required field.
+ let {
+ payerName,
+ payerEmail,
+ payerPhone,
+ } = await this._convertProfileAddressToPayerData(payerAddressGUID);
+ paymentSrv.changePayerDetail(
+ this.request.requestId,
+ payerName,
+ payerEmail,
+ payerPhone
+ );
+ }
+ },
+
+ async onChangePaymentMethod({
+ selectedPaymentCardBillingAddressGUID: billingAddressGUID,
+ }) {
+ const methodName = "basic-card";
+ let methodDetails;
+ try {
+ let billingAddress = await this._convertProfileAddressToPaymentAddress(
+ billingAddressGUID
+ );
+ const basicCardChangeDetails = Cc[
+ "@mozilla.org/dom/payments/basiccard-change-details;1"
+ ].createInstance(Ci.nsIBasicCardChangeDetails);
+ basicCardChangeDetails.initData(billingAddress);
+ methodDetails = basicCardChangeDetails.QueryInterface(
+ Ci.nsIMethodChangeDetails
+ );
+ } catch (ex) {
+ // TODO (Bug 1498403): Some kind of "credit card storage error" here, perhaps asking user
+ // to re-enter credit card # from management UI.
+ Cu.reportError(ex);
+ return;
+ }
+
+ paymentSrv.changePaymentMethod(
+ this.request.requestId,
+ methodName,
+ methodDetails
+ );
+ },
+
+ async onChangeShippingAddress({ shippingAddressGUID }) {
+ if (shippingAddressGUID) {
+ // If a shipping address was de-selected e.g. the selected address was deleted, we'll
+ // just wait to send the address change when the shipping address is eventually selected
+ // before clicking Pay since it's a required field.
+ let address = await this._convertProfileAddressToPaymentAddress(
+ shippingAddressGUID
+ );
+ paymentSrv.changeShippingAddress(this.request.requestId, address);
+ }
+ },
+
+ 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);
+ },
+
+ onCloseDialogMessage() {
+ // The PR is complete(), just close the dialog
+ paymentUISrv.closePayment(this.request.requestId);
+ },
+
+ async onUpdateAutofillRecord(collectionName, record, guid, messageID) {
+ let responseMessage = {
+ guid,
+ messageID,
+ stateChange: {},
+ };
+ try {
+ let isTemporary = record.isTemporary;
+ let collection = isTemporary
+ ? this.temporaryStore[collectionName]
+ : formAutofillStorage[collectionName];
+
+ if (guid) {
+ // We want to preserve old properties since the edit forms are often
+ // shown without all fields visible/enabled and we don't want those
+ // fields to be blanked upon saving. Examples of hidden/disabled fields:
+ // email, cc-number, mailing-address on the payer forms, and payer fields
+ // not requested in the payer form.
+ let preserveOldProperties = true;
+ await collection.update(guid, record, preserveOldProperties);
+ } else {
+ responseMessage.guid = await collection.add(record);
+ }
+
+ if (isTemporary && collectionName == "addresses") {
+ // there will be no formautofill-storage-changed event to update state
+ // so add updated collection here
+ Object.assign(responseMessage.stateChange, {
+ tempAddresses: this.temporaryStore.addresses.getAll(),
+ });
+ }
+ if (isTemporary && collectionName == "creditCards") {
+ // there will be no formautofill-storage-changed event to update state
+ // so add updated collection here
+ Object.assign(responseMessage.stateChange, {
+ tempBasicCards: this.fetchTempPaymentCards(),
+ });
+ }
+ } catch (ex) {
+ responseMessage.error = true;
+ Cu.reportError(ex);
+ } finally {
+ this.sendMessageToContent(
+ "updateAutofillRecord:Response",
+ responseMessage
+ );
+ }
+ },
+
+ /**
+ * @implements {nsIObserver}
+ * @param {nsISupports} subject
+ * @param {string} topic
+ * @param {string} data
+ */
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "formautofill-storage-changed": {
+ if (data == "notifyUsed") {
+ break;
+ }
+ this.onAutofillStorageChange();
+ break;
+ }
+ case "message-manager-close": {
+ if (this.mm && subject == this.mm) {
+ // Remove the observer to avoid message manager errors while the dialog
+ // is closing and tests are cleaning up autofill storage.
+ Services.obs.removeObserver(this, "formautofill-storage-changed");
+ }
+ break;
+ }
+ }
+ },
+
+ receiveMessage({ data }) {
+ let { messageType } = data;
+
+ switch (messageType) {
+ case "debugFrame": {
+ this.debugFrame();
+ break;
+ }
+ case "initializeRequest": {
+ this.initializeFrame();
+ break;
+ }
+ case "changePayerAddress": {
+ this.onChangePayerAddress(data);
+ break;
+ }
+ case "changePaymentMethod": {
+ this.onChangePaymentMethod(data);
+ break;
+ }
+ case "changeShippingAddress": {
+ this.onChangeShippingAddress(data);
+ break;
+ }
+ case "changeShippingOption": {
+ this.onChangeShippingOption(data);
+ break;
+ }
+ case "closeDialog": {
+ this.onCloseDialogMessage();
+ break;
+ }
+ case "openPreferences": {
+ this.onOpenPreferences();
+ break;
+ }
+ case "paymentCancel": {
+ this.onPaymentCancel();
+ break;
+ }
+ case "paymentDialogReady": {
+ this.frameWeakRef.get().dispatchEvent(
+ new Event("tabmodaldialogready", {
+ bubbles: true,
+ })
+ );
+ break;
+ }
+ case "pay": {
+ this.onPay(data);
+ break;
+ }
+ case "updateAutofillRecord": {
+ this.onUpdateAutofillRecord(
+ data.collectionName,
+ data.record,
+ data.guid,
+ data.messageID
+ );
+ break;
+ }
+ default: {
+ throw new Error(
+ `paymentDialogWrapper: Unexpected messageType: ${messageType}`
+ );
+ }
+ }
+ },
+};
diff --git a/browser/components/payments/docs/index.rst b/browser/components/payments/docs/index.rst
new file mode 100644
index 0000000000..c926d45d1f
--- /dev/null
+++ b/browser/components/payments/docs/index.rst
@@ -0,0 +1,111 @@
+==============
+WebPayments UI
+==============
+
+User Interface for the WebPayments `Payment Request API <https://w3c.github.io/browser-payment-api/>`_ and `Payment Handler API <https://w3c.github.io/payment-handler/>`_.
+
+
+ `Project Wiki <https://wiki.mozilla.org/Firefox/Features/Web_Payments>`_ |
+ `#payments on IRC <ircs://irc.mozilla.org:6697/payments>`_ |
+ `File a bug <https://bugzilla.mozilla.org/enter_bug.cgi?product=Firefox&component=WebPayments%20UI&status_whiteboard=[webpayments]%20[triage]>`_
+
+JSDoc style comments are used within the JS files of the component. This document will focus on higher-level and shared concepts.
+
+.. toctree::
+ :maxdepth: 5
+
+
+Debugging/Development
+=====================
+
+Relevant preferences: ``dom.payments.*``
+
+Must Have Electrolysis
+----------------------
+
+Web Payments `does not work without e10s <https://bugzilla.mozilla.org/show_bug.cgi?id=1365964>`_!
+
+Logging
+-------
+
+Set the pref ``dom.payments.loglevel`` to "Debug" to increase the verbosity of console messages.
+
+Unprivileged UI Development
+---------------------------
+During development of the unprivileged custom elements, you can load the dialog in a tab with
+the url `resource://payments/paymentRequest.xhtml`.
+You can then use the debugging console to load sample data. Autofill add/edit form strings
+will not appear when developing this way until they are converted to FTL.
+You can force localization of Form Autofill strings using the following in the Browser Console when
+the `paymentRequest.xhtml` tab is selected then reloading::
+
+ gBrowser.selectedBrowser.messageManager.loadFrameScript("chrome://formautofill/content/l10n.js", true)
+
+
+Debugging Console
+-----------------
+
+To open the debugging console in the dialog, use the keyboard shortcut
+**Ctrl-Alt-d (Ctrl-Option-d on macOS)**. While loading `paymentRequest.xhtml` directly in the
+browser, add `?debug=1` to have the debugging console open by default.
+
+Debugging the unprivileged frame with the developer tools
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To open a debugger in the context of the remote payment frame, click the "Debug frame" button in the
+debugging console.
+
+Use the ``tabs`` variable in the Browser Content Toolbox's console to access the frame contents.
+There can be multiple frames loaded in the same process so you will need to find the correct tab
+in the array by checking the file name is `paymentRequest.xhtml` (e.g. ``tabs[0].content.location``).
+
+
+Dialog Architecture
+===================
+
+A remote ``<xul:browser="true" remote="true">`` is `added to the Browser Window <https://searchfox.org/mozilla-central/search?q=%20_createPaymentFrame&case=false&regexp=false&path=>`_
+containing unprivileged XHTML (paymentRequest.xhtml).
+Keeping the dialog contents unprivileged is useful since the dialog will render payment line items and shipping options that are provided by web developers and should therefore be considered untrusted.
+In order to communicate across the process boundary a privileged frame script (`paymentDialogFrameScript.js`) is loaded into the iframe to relay messages.
+This is because the unprivileged document cannot access message managers.
+Instead, all communication across the privileged/unprivileged boundary is done via custom DOM events:
+
+* A ``paymentContentToChrome`` event is dispatched when the dialog contents want to communicate with the privileged dialog wrapper.
+* A ``paymentChromeToContent`` event is dispatched on the ``window`` with the ``detail`` property populated when the privileged dialog wrapper communicates with the unprivileged dialog.
+
+These events are converted to/from message manager messages of the same name to communicate to the other process.
+The purpose of `paymentDialogFrameScript.js` is to simply convert unprivileged DOM events to/from messages from the other process.
+
+The dialog depends on the add/edit forms and storage from :doc:`Form Autofill </browser/extensions/formautofill/docs/index>` for addresses and credit cards.
+
+Communication with the DOM
+--------------------------
+
+Communication from the DOM to the UI happens via the `paymentUIService.js` (implementing ``nsIPaymentUIService``).
+The UI talks to the DOM code via the ``nsIPaymentRequestService`` interface.
+
+
+Custom Elements
+---------------
+
+The Payment Request UI uses `Custom Elements <https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements>`_ for the UI components.
+
+Some guidelines:
+
+* There are some `mixins <https://searchfox.org/mozilla-central/source/browser/components/payments/res/mixins/>`_
+ to provide commonly needed functionality to a custom element.
+* `res/containers/ <https://searchfox.org/mozilla-central/source/browser/components/payments/res/containers/>`_
+ contains elements that react to application state changes,
+ `res/components/ <https://searchfox.org/mozilla-central/source/browser/components/payments/res/components>`_
+ contains elements that aren't connected to the state directly.
+* Elements should avoid having their own internal/private state and should react to state changes.
+ Containers primarily use the application state (``requestStore``) while components primarily use attributes.
+* If you're overriding a lifecycle callback, don't forget to call that method on
+ ``super`` from the implementation to ensure that mixins and ancestor classes
+ work properly.
+* From within a custom element, don't use ``document.getElementById`` or
+ ``document.querySelector*`` because they can return elements that are outside
+ of the component, thus breaking the modularization. It can also cause problems
+ if the elements you're looking for aren't attached to the document yet. Use
+ ``querySelector*`` on ``this`` (the custom element) or one of its descendants
+ instead.
diff --git a/browser/components/payments/jar.mn b/browser/components/payments/jar.mn
new file mode 100644
index 0000000000..73e8b00a2a
--- /dev/null
+++ b/browser/components/payments/jar.mn
@@ -0,0 +1,26 @@
+# 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/.
+
+browser.jar:
+% content payments %content/payments/
+ content/payments/paymentDialogFrameScript.js (content/paymentDialogFrameScript.js)
+ content/payments/paymentDialogWrapper.js (content/paymentDialogWrapper.js)
+
+% resource payments %res/payments/
+ res/payments (res/paymentRequest.*)
+ res/payments/components/ (res/components/*.css)
+ res/payments/components/ (res/components/*.js)
+ res/payments/components/ (res/components/*.svg)
+ res/payments/containers/ (res/containers/*.js)
+ res/payments/containers/ (res/containers/*.css)
+ res/payments/containers/ (res/containers/*.svg)
+ res/payments/debugging.css (res/debugging.css)
+ res/payments/debugging.html (res/debugging.html)
+ res/payments/debugging.js (res/debugging.js)
+ res/payments/formautofill/autofillEditForms.js (../../../browser/extensions/formautofill/content/autofillEditForms.js)
+ res/payments/formautofill/editAddress.xhtml (../../../browser/extensions/formautofill/content/editAddress.xhtml)
+ res/payments/formautofill/editCreditCard.xhtml (../../../browser/extensions/formautofill/content/editCreditCard.xhtml)
+ res/payments/unprivileged-fallbacks.js (res/unprivileged-fallbacks.js)
+ res/payments/mixins/ (res/mixins/*.js)
+ res/payments/PaymentsStore.js (res/PaymentsStore.js)
diff --git a/browser/components/payments/moz.build b/browser/components/payments/moz.build
new file mode 100644
index 0000000000..6c764f85fa
--- /dev/null
+++ b/browser/components/payments/moz.build
@@ -0,0 +1,36 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "WebPayments UI")
+
+EXTRA_JS_MODULES += [
+ "PaymentUIService.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+MOCHITEST_MANIFESTS += [
+ "test/mochitest/formautofill/mochitest.ini",
+ "test/mochitest/mochitest.ini",
+]
+
+SPHINX_TREES["docs"] = "docs"
+
+with Files("docs/**"):
+ SCHEDULES.exclusive = ["docs"]
+
+TESTING_JS_MODULES += [
+ "test/PaymentTestUtils.jsm",
+]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"]
diff --git a/browser/components/payments/res/PaymentsStore.js b/browser/components/payments/res/PaymentsStore.js
new file mode 100644
index 0000000000..7e439076d8
--- /dev/null
+++ b/browser/components/payments/res/PaymentsStore.js
@@ -0,0 +1,97 @@
+/* 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/. */
+
+/**
+ * The PaymentsStore class provides lightweight storage with an async publish/subscribe mechanism.
+ * Synchronous state changes are batched to improve application performance and to reduce partial
+ * state propagation.
+ */
+
+export default class PaymentsStore {
+ /**
+ * @param {object} [defaultState = {}] The initial state of the store.
+ */
+ constructor(defaultState = {}) {
+ this._defaultState = Object.assign({}, defaultState);
+ this._state = defaultState;
+ this._nextNotifification = 0;
+ this._subscribers = new Set();
+ }
+
+ /**
+ * Get the current state as a shallow clone with a shallow freeze.
+ * You shouldn't modify any part of the returned state object as that would bypass notifying
+ * subscribers and could lead to subscribers assuming old state.
+ *
+ * @returns {Object} containing the current state
+ */
+ getState() {
+ return Object.freeze(Object.assign({}, this._state));
+ }
+
+ /**
+ * Used for testing to reset to the default state from the constructor.
+ * @returns {Promise} returned by setState.
+ */
+ async reset() {
+ return this.setState(this._defaultState);
+ }
+
+ /**
+ * Augment the current state with the keys of `obj` and asynchronously notify
+ * state subscribers. As a result, multiple synchronous state changes will lead
+ * to a single subscriber notification which leads to better performance and
+ * reduces partial state changes.
+ *
+ * @param {Object} obj The object to augment the state with. Keys in the object
+ * will be shallow copied with Object.assign.
+ *
+ * @example If the state is currently {a:3} then setState({b:"abc"}) will result in a state of
+ * {a:3, b:"abc"}.
+ */
+ async setState(obj) {
+ Object.assign(this._state, obj);
+ let thisChangeNum = ++this._nextNotifification;
+
+ // Let any synchronous setState calls that happen after the current setState call
+ // complete first.
+ // Their effects on the state will be batched up before the callback is actually called below.
+ await Promise.resolve();
+
+ // Don't notify for state changes that are no longer the most recent. We only want to call the
+ // callback once with the latest state.
+ if (thisChangeNum !== this._nextNotifification) {
+ return;
+ }
+
+ for (let subscriber of this._subscribers) {
+ try {
+ subscriber.stateChangeCallback(this.getState());
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ /**
+ * Subscribe the object to state changes notifications via a `stateChangeCallback` method.
+ *
+ * @param {Object} component to receive state change callbacks via a `stateChangeCallback` method.
+ * If the component is already subscribed, do nothing.
+ */
+ subscribe(component) {
+ if (this._subscribers.has(component)) {
+ return;
+ }
+
+ this._subscribers.add(component);
+ }
+
+ /**
+ * @param {Object} component to stop receiving state change callbacks.
+ */
+ unsubscribe(component) {
+ this._subscribers.delete(component);
+ }
+}
diff --git a/browser/components/payments/res/components/accepted-cards.css b/browser/components/payments/res/components/accepted-cards.css
new file mode 100644
index 0000000000..575e17b4f1
--- /dev/null
+++ b/browser/components/payments/res/components/accepted-cards.css
@@ -0,0 +1,108 @@
+/* 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/. */
+
+accepted-cards {
+ margin: 1em 0;
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: first baseline;
+}
+
+.accepted-cards-label {
+ display: inline-block;
+ font-size: smaller;
+ flex: 0 2 content;
+ white-space: nowrap;
+}
+
+.accepted-cards-list {
+ display: inline-block;
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ flex: 2 1 auto;
+}
+
+.accepted-cards-list > .accepted-cards-item {
+ display: inline-block;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ margin: 5px 0;
+ margin-inline-start: 10px;
+ vertical-align: middle;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
+}
+
+/* placeholders for specific card icons we don't yet have assets for */
+accepted-cards:not(.branded) .accepted-cards-item[data-network-id] {
+ width: 48px;
+ text-align: center;
+ background-image: url("./card-icon.svg");
+ -moz-context-properties: fill-opacity;
+ fill-opacity: 0.5;
+}
+accepted-cards:not(.branded) .accepted-cards-item[data-network-id]::after {
+ box-sizing: border-box;
+ content: attr(data-network-id);
+ padding: 8px 4px 0 4px;
+ text-align: center;
+ font-size: 0.7rem;
+ display: inline-block;
+ overflow: hidden;
+ width: 100%;
+}
+
+/*
+ We use .png / @2x.png images where we don't yet have a vector version of a logo
+*/
+.accepted-cards-item[data-network-id="amex"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-amex.png");
+}
+
+.accepted-cards-item[data-network-id="cartebancaire"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-cartebancaire.png");
+}
+
+.accepted-cards-item[data-network-id="diners"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-diners.svg");
+}
+
+.accepted-cards-item[data-network-id="discover"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-discover.png");
+}
+
+.accepted-cards-item[data-network-id="jcb"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-jcb.svg");
+}
+
+.accepted-cards-item[data-network-id="mastercard"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-mastercard.svg");
+}
+
+.accepted-cards-item[data-network-id="mir"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-mir.svg");
+}
+
+.accepted-cards-item[data-network-id="unionpay"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-unionpay.svg");
+}
+
+.accepted-cards-item[data-network-id="visa"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-visa.svg");
+}
+
+@media (min-resolution: 1.1dppx) {
+ .accepted-cards-item[data-network-id="amex"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-amex@2x.png");
+ }
+ .accepted-cards-item[data-network-id="cartebancaire"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-cartebancaire@2x.png");
+ }
+ .accepted-cards-item[data-network-id="discover"] {
+ background-image: url("chrome://formautofill/content/third-party/cc-logo-discover@2x.png");
+ }
+}
diff --git a/browser/components/payments/res/components/accepted-cards.js b/browser/components/payments/res/components/accepted-cards.js
new file mode 100644
index 0000000000..c66e040576
--- /dev/null
+++ b/browser/components/payments/res/components/accepted-cards.js
@@ -0,0 +1,75 @@
+/* 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 PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <accepted-cards></accepted-cards>
+ */
+
+export default class AcceptedCards extends PaymentStateSubscriberMixin(
+ HTMLElement
+) {
+ constructor() {
+ super();
+
+ this._listEl = document.createElement("ul");
+ this._listEl.classList.add("accepted-cards-list");
+ this._labelEl = document.createElement("span");
+ this._labelEl.classList.add("accepted-cards-label");
+ }
+
+ connectedCallback() {
+ this.label = this.getAttribute("label");
+ this.appendChild(this._labelEl);
+
+ this._listEl.textContent = "";
+ let allNetworks = PaymentDialogUtils.getCreditCardNetworks();
+ for (let network of allNetworks) {
+ let item = document.createElement("li");
+ item.classList.add("accepted-cards-item");
+ item.dataset.networkId = network;
+ item.setAttribute("aria-role", "image");
+ item.setAttribute("aria-label", network);
+ this._listEl.appendChild(item);
+ }
+ let isBranded = PaymentDialogUtils.isOfficialBranding();
+ this.classList.toggle("branded", isBranded);
+ this.appendChild(this._listEl);
+ // Only call the connected super callback(s) once our markup is fully
+ // connected
+ super.connectedCallback();
+ }
+
+ render(state) {
+ let basicCardMethod = state.request.paymentMethods.find(
+ method => method.supportedMethods == "basic-card"
+ );
+ let merchantNetworks =
+ basicCardMethod &&
+ basicCardMethod.data &&
+ basicCardMethod.data.supportedNetworks;
+ if (merchantNetworks && merchantNetworks.length) {
+ for (let item of this._listEl.children) {
+ let network = item.dataset.networkId;
+ item.hidden = !(network && merchantNetworks.includes(network));
+ }
+ this.hidden = false;
+ } else {
+ // hide the whole list if the merchant didn't specify a preference
+ this.hidden = true;
+ }
+ }
+
+ set label(value) {
+ this._labelEl.textContent = value;
+ }
+
+ get acceptedItems() {
+ return Array.from(this._listEl.children).filter(item => !item.hidden);
+ }
+}
+
+customElements.define("accepted-cards", AcceptedCards);
diff --git a/browser/components/payments/res/components/address-option.css b/browser/components/payments/res/components/address-option.css
new file mode 100644
index 0000000000..9619048ae6
--- /dev/null
+++ b/browser/components/payments/res/components/address-option.css
@@ -0,0 +1,29 @@
+/* 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/. */
+
+address-option.rich-option {
+ grid-row-gap: 5px;
+}
+
+address-option > .line {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+address-option > .line:empty {
+ /* Hide the 2nd line in cases where it's empty
+ (e.g. payer field with one or two fields requested) */
+ display: none;
+}
+
+address-option > .line > span {
+ white-space: nowrap;
+}
+
+address-option > .line > span:empty::before {
+ /* Show the string for missing fields in grey when the field is empty */
+ color: GrayText;
+ content: attr(data-missing-string);
+}
diff --git a/browser/components/payments/res/components/address-option.js b/browser/components/payments/res/components/address-option.js
new file mode 100644
index 0000000000..2661f3e231
--- /dev/null
+++ b/browser/components/payments/res/components/address-option.js
@@ -0,0 +1,159 @@
+/* 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 ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js";
+import RichOption from "./rich-option.js";
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * Up to two-line address display. After bug 1475684 this will also be used for
+ * the single-line <option> substitute too.
+ *
+ * <rich-select>
+ * <address-option guid="98hgvnbmytfc"
+ * address-level1="MI"
+ * address-level2="Some City"
+ * email="foo@example.com"
+ * country="USA"
+ * name="Jared Wein"
+ * postal-code="90210"
+ * street-address="1234 Anywhere St"
+ * tel="+1 650 555-5555"></address-option>
+ * </rich-select>
+ *
+ * Attribute names follow FormAutofillStorage.jsm.
+ */
+
+export default class AddressOption extends ObservedPropertiesMixin(RichOption) {
+ static get recordAttributes() {
+ return [
+ "address-level1",
+ "address-level2",
+ "address-level3",
+ "country",
+ "email",
+ "guid",
+ "name",
+ "organization",
+ "postal-code",
+ "street-address",
+ "tel",
+ ];
+ }
+
+ static get observedAttributes() {
+ return RichOption.observedAttributes.concat(
+ AddressOption.recordAttributes,
+ "address-fields",
+ "break-after-nth-field",
+ "data-field-separator"
+ );
+ }
+
+ constructor() {
+ super();
+
+ this._line1 = document.createElement("div");
+ this._line1.classList.add("line");
+ this._line2 = document.createElement("div");
+ this._line2.classList.add("line");
+
+ for (let name of AddressOption.recordAttributes) {
+ this[`_${name}`] = document.createElement("span");
+ this[`_${name}`].classList.add(name);
+ // XXX Bug 1490816: Use appropriate strings
+ let missingValueString =
+ name.replace(/(-|^)([a-z])/g, ($0, $1, $2) => {
+ return $1.replace("-", " ") + $2.toUpperCase();
+ }) + " Missing";
+ this[`_${name}`].dataset.missingString = missingValueString;
+ }
+ }
+
+ connectedCallback() {
+ this.appendChild(this._line1);
+ this.appendChild(this._line2);
+ super.connectedCallback();
+ }
+
+ static formatSingleLineLabel(address, addressFields) {
+ return PaymentDialogUtils.getAddressLabel(address, addressFields);
+ }
+
+ get requiredFields() {
+ if (this.hasAttribute("address-fields")) {
+ let names = this.getAttribute("address-fields")
+ .trim()
+ .split(/\s+/);
+ if (names.length) {
+ return names;
+ }
+ }
+
+ return [
+ // "address-level1", // TODO: bug 1481481 - not required for some countries e.g. DE
+ "address-level2",
+ "country",
+ "name",
+ "postal-code",
+ "street-address",
+ ];
+ }
+
+ render() {
+ // Clear the lines of the fields so we can append only the ones still
+ // visible in the correct order below.
+ this._line1.textContent = "";
+ this._line2.textContent = "";
+
+ // Fill the fields with their text/strings.
+ // Fall back to empty strings to prevent 'null' from appearing.
+ for (let name of AddressOption.recordAttributes) {
+ let camelCaseName = super.constructor.kebabToCamelCase(name);
+ let fieldEl = this[`_${name}`];
+ fieldEl.textContent = this[camelCaseName] || "";
+ }
+
+ let { fieldsOrder } = PaymentDialogUtils.getFormFormat(this.country);
+ // A subset of the requested fields may be returned if the fields don't apply to the country.
+ let requestedVisibleFields = this.addressFields || "mailing-address";
+ let visibleFields = EditAddress.computeVisibleFields(
+ fieldsOrder,
+ requestedVisibleFields
+ );
+ let visibleFieldCount = 0;
+ let requiredFields = this.requiredFields;
+ // Start by populating line 1
+ let lineEl = this._line1;
+ // Which field number to start line 2 after.
+ let breakAfterNthField = this.breakAfterNthField || 2;
+
+ // Now actually place the fields in the proper place on the lines.
+ for (let field of visibleFields) {
+ let fieldEl = this[`_${field.fieldId}`];
+ if (!fieldEl) {
+ log.warn(`address-option render: '${field.fieldId}' doesn't exist`);
+ continue;
+ }
+
+ if (!fieldEl.textContent && !requiredFields.includes(field.fieldId)) {
+ // The field is empty and we don't need to show "Missing …" so don't append.
+ continue;
+ }
+
+ if (lineEl.children.length) {
+ lineEl.append(this.dataset.fieldSeparator);
+ }
+ lineEl.appendChild(fieldEl);
+
+ // Add a break after this field, if requested.
+ if (++visibleFieldCount == breakAfterNthField) {
+ lineEl = this._line2;
+ }
+ }
+ }
+}
+
+customElements.define("address-option", AddressOption);
diff --git a/browser/components/payments/res/components/basic-card-option.css b/browser/components/payments/res/components/basic-card-option.css
new file mode 100644
index 0000000000..29776b0722
--- /dev/null
+++ b/browser/components/payments/res/components/basic-card-option.css
@@ -0,0 +1,40 @@
+/* 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/. */
+
+basic-card-option {
+ grid-column-gap: 1em;
+ grid-template-areas: "cc-type cc-number cc-exp cc-name";
+ /* Need to set a minimum width for the cc-type svg in the <img> to fill */
+ grid-template-columns: minmax(1em, auto);
+ justify-content: start;
+}
+
+basic-card-option > .cc-number,
+basic-card-option > .cc-name,
+basic-card-option > .cc-exp,
+basic-card-option > .cc-type {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+basic-card-option > .cc-number {
+ grid-area: cc-number;
+ /* Don't truncate the card number */
+ overflow: visible;
+}
+
+basic-card-option > .cc-name {
+ grid-area: cc-name;
+}
+
+basic-card-option > .cc-exp {
+ grid-area: cc-exp;
+}
+
+basic-card-option > .cc-type {
+ grid-area: cc-type;
+ height: 100%;
+ text-transform: capitalize;
+}
diff --git a/browser/components/payments/res/components/basic-card-option.js b/browser/components/payments/res/components/basic-card-option.js
new file mode 100644
index 0000000000..cda4a5d892
--- /dev/null
+++ b/browser/components/payments/res/components/basic-card-option.js
@@ -0,0 +1,89 @@
+/* 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";
+import RichOption from "./rich-option.js";
+
+/**
+ * <rich-select>
+ * <basic-card-option></basic-card-option>
+ * </rich-select>
+ */
+
+export default class BasicCardOption extends ObservedPropertiesMixin(
+ RichOption
+) {
+ static get recordAttributes() {
+ return ["cc-exp", "cc-name", "cc-number", "cc-type", "guid"];
+ }
+
+ static get observedAttributes() {
+ return RichOption.observedAttributes.concat(
+ BasicCardOption.recordAttributes
+ );
+ }
+
+ constructor() {
+ super();
+
+ for (let name of ["cc-name", "cc-number", "cc-exp", "cc-type"]) {
+ this[`_${name}`] = document.createElement(
+ name == "cc-type" ? "img" : "span"
+ );
+ this[`_${name}`].classList.add(name);
+ }
+ }
+
+ connectedCallback() {
+ for (let name of ["cc-name", "cc-number", "cc-exp", "cc-type"]) {
+ this.appendChild(this[`_${name}`]);
+ }
+ super.connectedCallback();
+ }
+
+ static formatCCNumber(ccNumber) {
+ // XXX: Bug 1470175 - This should probably be unified with CreditCard.jsm logic.
+ return ccNumber ? ccNumber.replace(/[*]{4,}/, "****") : "";
+ }
+
+ static formatSingleLineLabel(basicCard) {
+ let ccNumber = BasicCardOption.formatCCNumber(basicCard["cc-number"]);
+
+ // XXX Bug 1473772 - Hard-coded string
+ let ccExp = basicCard["cc-exp"] ? "Exp. " + basicCard["cc-exp"] : "";
+ let ccName = basicCard["cc-name"];
+ // XXX: Bug 1491040, displaying cc-type in this context may need its own localized string
+ let ccType = basicCard["cc-type"] || "";
+ // Filter out empty/undefined tokens before joining by three spaces
+ // (&nbsp; in the middle of two normal spaces to avoid them visually collapsing in HTML)
+ return [
+ ccType.replace(/^[a-z]/, $0 => $0.toUpperCase()),
+ ccNumber,
+ ccExp,
+ ccName,
+ // XXX Bug 1473772 - Hard-coded string:
+ ]
+ .filter(str => !!str)
+ .join(" \xa0 ");
+ }
+
+ get requiredFields() {
+ return BasicCardOption.recordAttributes;
+ }
+
+ render() {
+ this["_cc-name"].textContent = this.ccName || "";
+ this["_cc-number"].textContent = BasicCardOption.formatCCNumber(
+ this.ccNumber
+ );
+ // XXX Bug 1473772 - Hard-coded string:
+ this["_cc-exp"].textContent = this.ccExp ? "Exp. " + this.ccExp : "";
+ // XXX: Bug 1491040, displaying cc-type in this context may need its own localized string
+ this["_cc-type"].alt = this.ccType || "";
+ this["_cc-type"].src =
+ "chrome://formautofill/content/icon-credit-card-generic.svg";
+ }
+}
+
+customElements.define("basic-card-option", BasicCardOption);
diff --git a/browser/components/payments/res/components/card-icon.svg b/browser/components/payments/res/components/card-icon.svg
new file mode 100644
index 0000000000..1ea36d7fae
--- /dev/null
+++ b/browser/components/payments/res/components/card-icon.svg
@@ -0,0 +1,9 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32">
+ <rect x="0" y="0" width="48" height="32" rx="4" ry="4" fill="#000" fill-opacity="context-fill-opacity">
+ </rect>
+ <rect x="0" y="6" width="48" height="20" fill="#fff" fill-opacity="1">
+ </rect>
+</svg>
diff --git a/browser/components/payments/res/components/csc-input.js b/browser/components/payments/res/components/csc-input.js
new file mode 100644
index 0000000000..4497f8a789
--- /dev/null
+++ b/browser/components/payments/res/components/csc-input.js
@@ -0,0 +1,112 @@
+/* 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";
+
+/**
+ * <csc-input placeholder="CVV*"
+ default-value="123"
+ front-tooltip="Look on front of card for CSC"
+ back-tooltip="Look on back of card for CSC"></csc-input>
+ */
+
+export default class CscInput extends ObservedPropertiesMixin(HTMLElement) {
+ static get observedAttributes() {
+ return [
+ "back-tooltip",
+ "card-type",
+ "default-value",
+ "disabled",
+ "front-tooltip",
+ "placeholder",
+ "value",
+ ];
+ }
+ constructor({ useAlwaysVisiblePlaceholder, inputId } = {}) {
+ super();
+
+ this.useAlwaysVisiblePlaceholder = useAlwaysVisiblePlaceholder;
+
+ this._input = document.createElement("input");
+ this._input.id = inputId || "";
+ this._input.setAttribute("type", "text");
+ this._input.autocomplete = "off";
+ this._input.size = 3;
+ this._input.required = true;
+ // 3 or more digits
+ this._input.pattern = "[0-9]{3,}";
+ this._input.classList.add("security-code");
+ if (useAlwaysVisiblePlaceholder) {
+ this._label = document.createElement("span");
+ this._label.dataset.localization = "cardCVV";
+ this._label.className = "label-text";
+ }
+ this._tooltip = document.createElement("span");
+ this._tooltip.className = "info-tooltip csc";
+ this._tooltip.setAttribute("tabindex", "0");
+ this._tooltip.setAttribute("role", "tooltip");
+
+ // The parent connectedCallback calls its render method before
+ // our connectedCallback can run. This causes issues for parent
+ // code that is looking for all the form elements. Thus, we
+ // append the children during the constructor to make sure they
+ // be part of the DOM sooner.
+ this.appendChild(this._input);
+ if (this.useAlwaysVisiblePlaceholder) {
+ this.appendChild(this._label);
+ }
+ this.appendChild(this._tooltip);
+ }
+
+ connectedCallback() {
+ this.render();
+ }
+
+ render() {
+ if (this.defaultValue) {
+ let oldDefaultValue = this._input.defaultValue;
+ this._input.defaultValue = this.defaultValue;
+ if (this._input.defaultValue != oldDefaultValue) {
+ // Setting defaultValue will place a value in the field
+ // but doesn't trigger a 'change' event, which is needed
+ // to update the Pay button state on the summary page.
+ this._input.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ } else {
+ this._input.defaultValue = "";
+ }
+ if (this.value) {
+ // Setting the value will trigger form validation
+ // so only set the value if one has been provided.
+ this._input.value = this.value;
+ }
+ if (this.useAlwaysVisiblePlaceholder) {
+ this._label.textContent = this.placeholder || "";
+ } else {
+ this._input.placeholder = this.placeholder || "";
+ }
+ if (this.cardType == "amex") {
+ this._tooltip.setAttribute("aria-label", this.frontTooltip || "");
+ } else {
+ this._tooltip.setAttribute("aria-label", this.backTooltip || "");
+ }
+ }
+
+ get value() {
+ return this._input.value;
+ }
+
+ get isValid() {
+ return this._input.validity.valid;
+ }
+
+ set disabled(value) {
+ // This is kept out of render() since callers
+ // are expecting it to apply immediately.
+ this._input.disabled = value;
+ return !!value;
+ }
+}
+
+customElements.define("csc-input", CscInput);
diff --git a/browser/components/payments/res/components/currency-amount.js b/browser/components/payments/res/components/currency-amount.js
new file mode 100644
index 0000000000..5c8d07cb2d
--- /dev/null
+++ b/browser/components/payments/res/components/currency-amount.js
@@ -0,0 +1,63 @@
+/* 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/. */
+
+/**
+ * <currency-amount value="7.5" currency="USD" display-code></currency-amount>
+ */
+
+import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js";
+
+export default class CurrencyAmount extends ObservedPropertiesMixin(
+ HTMLElement
+) {
+ static get observedAttributes() {
+ return ["currency", "display-code", "value"];
+ }
+
+ constructor() {
+ super();
+ this._currencyAmountTextNode = document.createTextNode("");
+ this._currencyCodeElement = document.createElement("span");
+ this._currencyCodeElement.classList.add("currency-code");
+ }
+
+ render() {
+ this.append(this._currencyAmountTextNode, this._currencyCodeElement);
+ let currencyAmount = "";
+ let currencyCode = "";
+ try {
+ if (this.value && this.currency) {
+ let number = Number.parseFloat(this.value);
+ if (Number.isNaN(number) || !Number.isFinite(number)) {
+ throw new RangeError("currency-amount value must be a finite number");
+ }
+ const symbolFormatter = new Intl.NumberFormat(navigator.languages, {
+ style: "currency",
+ currency: this.currency,
+ currencyDisplay: "symbol",
+ });
+ currencyAmount = symbolFormatter.format(this.value);
+
+ if (this.displayCode !== null) {
+ // XXX: Bug 1473772 will move the separator to a Fluent string.
+ currencyAmount += " ";
+
+ const codeFormatter = new Intl.NumberFormat(navigator.languages, {
+ style: "currency",
+ currency: this.currency,
+ currencyDisplay: "code",
+ });
+ let parts = codeFormatter.formatToParts(this.value);
+ let currencyPart = parts.find(part => part.type == "currency");
+ currencyCode = currencyPart.value;
+ }
+ }
+ } finally {
+ this._currencyAmountTextNode.textContent = currencyAmount;
+ this._currencyCodeElement.textContent = currencyCode;
+ }
+ }
+}
+
+customElements.define("currency-amount", CurrencyAmount);
diff --git a/browser/components/payments/res/components/labelled-checkbox.js b/browser/components/payments/res/components/labelled-checkbox.js
new file mode 100644
index 0000000000..f26d982f4f
--- /dev/null
+++ b/browser/components/payments/res/components/labelled-checkbox.js
@@ -0,0 +1,59 @@
+/* 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 ["infoTooltip", "form", "label", "value"];
+ }
+ constructor() {
+ super();
+
+ this._label = document.createElement("label");
+ this._labelSpan = document.createElement("span");
+ this._infoTooltip = document.createElement("span");
+ this._infoTooltip.className = "info-tooltip";
+ this._infoTooltip.setAttribute("tabindex", "0");
+ this._infoTooltip.setAttribute("role", "tooltip");
+ 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._label.appendChild(this._infoTooltip);
+ this.render();
+ }
+
+ render() {
+ this._labelSpan.textContent = this.label;
+ this._infoTooltip.setAttribute("aria-label", this.infoTooltip);
+ // We don't use the ObservedPropertiesMixin behaviour because we want to be able to mirror
+ // form="" but ObservedPropertiesMixin removes attributes when "".
+ if (this.hasAttribute("form")) {
+ this._checkbox.setAttribute("form", this.getAttribute("form"));
+ } else {
+ this._checkbox.removeAttribute("form");
+ }
+ }
+
+ get checked() {
+ return this._checkbox.checked;
+ }
+
+ set checked(value) {
+ return (this._checkbox.checked = value);
+ }
+}
+
+customElements.define("labelled-checkbox", LabelledCheckbox);
diff --git a/browser/components/payments/res/components/payment-details-item.css b/browser/components/payments/res/components/payment-details-item.css
new file mode 100644
index 0000000000..3332698014
--- /dev/null
+++ b/browser/components/payments/res/components/payment-details-item.css
@@ -0,0 +1,12 @@
+/* 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/. */
+
+payment-details-item {
+ margin: 1px 0;
+ min-height: 2em;
+}
+
+payment-details-item > currency-amount {
+ text-align: end;
+}
diff --git a/browser/components/payments/res/components/payment-details-item.js b/browser/components/payments/res/components/payment-details-item.js
new file mode 100644
index 0000000000..c2fe31ccfd
--- /dev/null
+++ b/browser/components/payments/res/components/payment-details-item.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/. */
+
+/**
+ * <ul>
+ * <payment-details-item
+ label="Some item"
+ amount-value="1.00"
+ amount-currency="USD"></payment-details-item>
+ * </ul>
+ */
+
+import CurrencyAmount from "./currency-amount.js";
+import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js";
+
+export default class PaymentDetailsItem extends ObservedPropertiesMixin(
+ HTMLElement
+) {
+ static get observedAttributes() {
+ return ["label", "amount-currency", "amount-value"];
+ }
+
+ constructor() {
+ super();
+ this._label = document.createElement("span");
+ this._label.classList.add("label");
+ this._currencyAmount = new CurrencyAmount();
+ }
+
+ connectedCallback() {
+ this.appendChild(this._label);
+ this.appendChild(this._currencyAmount);
+
+ if (super.connectedCallback) {
+ super.connectedCallback();
+ }
+ }
+
+ render() {
+ this._currencyAmount.value = this.amountValue;
+ this._currencyAmount.currency = this.amountCurrency;
+ this._label.textContent = this.label;
+ }
+}
+
+customElements.define("payment-details-item", PaymentDetailsItem);
diff --git a/browser/components/payments/res/components/payment-request-page.js b/browser/components/payments/res/components/payment-request-page.js
new file mode 100644
index 0000000000..76b41f178e
--- /dev/null
+++ b/browser/components/payments/res/components/payment-request-page.js
@@ -0,0 +1,36 @@
+/* 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/. */
+
+/**
+ * <payment-request-page></payment-request-page>
+ */
+
+export default class PaymentRequestPage extends HTMLElement {
+ constructor() {
+ super();
+
+ this.classList.add("page");
+
+ this.pageTitleHeading = document.createElement("h2");
+
+ // The body and footer may be pre-defined in the template so re-use them if they exist.
+ this.body =
+ this.querySelector(":scope > .page-body") ||
+ document.createElement("div");
+ this.body.classList.add("page-body");
+
+ this.footer =
+ this.querySelector(":scope > footer") || document.createElement("footer");
+ }
+
+ connectedCallback() {
+ // The heading goes inside the body so it scrolls.
+ this.body.prepend(this.pageTitleHeading);
+ this.appendChild(this.body);
+
+ this.appendChild(this.footer);
+ }
+}
+
+customElements.define("payment-request-page", PaymentRequestPage);
diff --git a/browser/components/payments/res/components/rich-option.js b/browser/components/payments/res/components/rich-option.js
new file mode 100644
index 0000000000..d82697de20
--- /dev/null
+++ b/browser/components/payments/res/components/rich-option.js
@@ -0,0 +1,26 @@
+/* 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/. */
+
+/**
+ * <rich-select>
+ * <rich-option></rich-option>
+ * </rich-select>
+ */
+
+import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js";
+
+export default class RichOption extends ObservedPropertiesMixin(HTMLElement) {
+ static get observedAttributes() {
+ return ["selected", "value"];
+ }
+
+ connectedCallback() {
+ this.classList.add("rich-option");
+ this.render();
+ }
+
+ render() {}
+}
+
+customElements.define("rich-option", RichOption);
diff --git a/browser/components/payments/res/components/rich-select.css b/browser/components/payments/res/components/rich-select.css
new file mode 100644
index 0000000000..8ec2fead3d
--- /dev/null
+++ b/browser/components/payments/res/components/rich-select.css
@@ -0,0 +1,58 @@
+/* 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/. */
+
+rich-select {
+ /* Include the padding in the max-width calculation so that we truncate rather
+ than grow wider than 100% of the parent. */
+ box-sizing: border-box;
+ display: block;
+ /* Has to be the same as `payment-method-picker > input`: */
+ margin: 10px 0;
+ /* Padding for the dropmarker (copied from common.css) */
+ padding-inline-end: 24px;
+ position: relative;
+ /* Don't allow the <rich-select> to grow wider than the container so that we
+ truncate with text-overflow for long options instead. */
+ max-width: 100%;
+}
+
+/* Focusing on the underlying select element outlines the outer
+ rich-select wrapper making it appear like rich-select is focused. */
+rich-select:focus-within > select {
+ outline: 1px dotted var(--in-content-text-color);
+}
+
+/*
+ * The HTML select element is hidden and placed on the rich-option
+ * element to make it look like clicking on the rich-option element
+ * in the closed state opens the HTML select dropdown. */
+rich-select > select {
+ /* Hide the text from the closed state so that the text/layout from
+ <rich-option> won't overlap it. The !important matches common.css. */
+ color: transparent !important;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+
+rich-select > select > option {
+ /* Reset the text color in the popup/open state */
+ color: var(--in-content-text-color);
+}
+
+.rich-option {
+ display: grid;
+ padding: 8px;
+}
+
+.rich-select-selected-option {
+ /* Clicks on the selected rich option should go to the <select> below to open the popup */
+ pointer-events: none;
+ /* Use position:relative so this is positioned on top of the <select> which
+ also has position:relative. */
+ position: relative;
+}
diff --git a/browser/components/payments/res/components/rich-select.js b/browser/components/payments/res/components/rich-select.js
new file mode 100644
index 0000000000..0226b32a79
--- /dev/null
+++ b/browser/components/payments/res/components/rich-select.js
@@ -0,0 +1,104 @@
+/* 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 HandleEventMixin from "../mixins/HandleEventMixin.js";
+import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js";
+import RichOption from "./rich-option.js";
+
+/**
+ * <rich-select>
+ * <rich-option></rich-option>
+ * </rich-select>
+ *
+ * Note: The only supported way to change the selected option is via the
+ * `value` setter.
+ */
+export default class RichSelect extends HandleEventMixin(
+ ObservedPropertiesMixin(HTMLElement)
+) {
+ static get observedAttributes() {
+ return ["disabled", "hidden"];
+ }
+
+ constructor() {
+ super();
+ this.popupBox = document.createElement("select");
+ }
+
+ connectedCallback() {
+ // the popupBox element may change in between constructor and being connected
+ // so wait until connected before listening to events on it
+ this.popupBox.addEventListener("change", this);
+ this.appendChild(this.popupBox);
+ this.render();
+ }
+
+ get selectedOption() {
+ return this.getOptionByValue(this.value);
+ }
+
+ get selectedRichOption() {
+ // XXX: Bug 1475684 - This can be removed once `selectedOption` returns a
+ // RichOption which extends HTMLOptionElement.
+ return this.querySelector(":scope > .rich-select-selected-option");
+ }
+
+ get value() {
+ return this.popupBox.value;
+ }
+
+ set value(guid) {
+ this.popupBox.value = guid;
+ this.render();
+ }
+
+ getOptionByValue(value) {
+ return this.popupBox.querySelector(
+ `:scope > [value="${CSS.escape(value)}"]`
+ );
+ }
+
+ onChange(event) {
+ // Since the render function depends on the popupBox's value, we need to
+ // re-render if the value changes.
+ this.render();
+ }
+
+ render() {
+ let selectedRichOption = this.querySelector(
+ ":scope > .rich-select-selected-option"
+ );
+ if (selectedRichOption) {
+ selectedRichOption.remove();
+ }
+
+ if (this.value) {
+ let optionType = this.getAttribute("option-type");
+ if (!selectedRichOption || selectedRichOption.localName != optionType) {
+ selectedRichOption = document.createElement(optionType);
+ }
+
+ let option = this.getOptionByValue(this.value);
+ let attributeNames = selectedRichOption.constructor.observedAttributes;
+ for (let attributeName of attributeNames) {
+ let attributeValue = option.getAttribute(attributeName);
+ if (attributeValue) {
+ selectedRichOption.setAttribute(attributeName, attributeValue);
+ } else {
+ selectedRichOption.removeAttribute(attributeName);
+ }
+ }
+ } else {
+ selectedRichOption = new RichOption();
+ selectedRichOption.textContent = "(None selected)"; // XXX: bug 1473772
+ }
+ selectedRichOption.classList.add("rich-select-selected-option");
+ // Hide the rich-option from a11y tools since the native <select> will
+ // already provide the selected option label.
+ selectedRichOption.setAttribute("aria-hidden", "true");
+ selectedRichOption = this.appendChild(selectedRichOption);
+ }
+}
+
+customElements.define("rich-select", RichSelect);
diff --git a/browser/components/payments/res/components/shipping-option.css b/browser/components/payments/res/components/shipping-option.css
new file mode 100644
index 0000000000..6c84c5eb84
--- /dev/null
+++ b/browser/components/payments/res/components/shipping-option.css
@@ -0,0 +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/. */
+
+shipping-option.rich-option {
+ display: block;
+ /* Below properties are to support truncating with an ellipsis for long options */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+shipping-option > .label,
+shipping-option > .amount {
+ white-space: nowrap;
+}
diff --git a/browser/components/payments/res/components/shipping-option.js b/browser/components/payments/res/components/shipping-option.js
new file mode 100644
index 0000000000..1832546463
--- /dev/null
+++ b/browser/components/payments/res/components/shipping-option.js
@@ -0,0 +1,65 @@
+/* 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 CurrencyAmount from "./currency-amount.js";
+import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js";
+import RichOption from "./rich-option.js";
+
+/**
+ * <rich-select>
+ * <shipping-option></shipping-option>
+ * </rich-select>
+ */
+
+export default class ShippingOption extends ObservedPropertiesMixin(
+ RichOption
+) {
+ static get recordAttributes() {
+ return ["label", "amount-currency", "amount-value"];
+ }
+
+ static get observedAttributes() {
+ return RichOption.observedAttributes.concat(
+ ShippingOption.recordAttributes
+ );
+ }
+
+ constructor() {
+ super();
+
+ this.amount = null;
+ this._currencyAmount = new CurrencyAmount();
+ this._currencyAmount.classList.add("amount");
+ this._label = document.createElement("span");
+ this._label.classList.add("label");
+ }
+
+ connectedCallback() {
+ this.appendChild(this._currencyAmount);
+ this.append(" ");
+ this.appendChild(this._label);
+ super.connectedCallback();
+ }
+
+ static formatSingleLineLabel(option) {
+ let amount = new CurrencyAmount();
+ amount.value = option.amount.value;
+ amount.currency = option.amount.currency;
+ amount.render();
+
+ return amount.textContent + " " + option.label;
+ }
+
+ render() {
+ this._label.textContent = this.label;
+ this._currencyAmount.currency = this.amountCurrency;
+ this._currencyAmount.value = this.amountValue;
+ // Need to call render after setting these properties
+ // if we want the amount to get displayed in the same
+ // render pass as the label.
+ this._currencyAmount.render();
+ }
+}
+
+customElements.define("shipping-option", ShippingOption);
diff --git a/browser/components/payments/res/containers/address-form.css b/browser/components/payments/res/containers/address-form.css
new file mode 100644
index 0000000000..484610414c
--- /dev/null
+++ b/browser/components/payments/res/containers/address-form.css
@@ -0,0 +1,55 @@
+/* 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/. */
+
+.error-text {
+ color: #fff;
+ background-color: #d70022;
+ border-radius: 2px;
+ margin: 5px 3px 0 3px;
+ /* The padding-top and padding-bottom are referenced by address-form.js */ /* TODO */
+ padding: 5px 12px;
+ position: absolute;
+ z-index: 1;
+ pointer-events: none;
+ top: 100%;
+ visibility: hidden;
+}
+
+/* ::before is the error on the error text panel */
+:is(input, textarea, select) ~ .error-text::before {
+ background-color: #d70022;
+ top: -7px;
+ content: '.';
+ height: 16px;
+ position: absolute;
+ text-indent: -999px;
+ transform: rotate(45deg);
+ white-space: nowrap;
+ width: 16px;
+ z-index: -1
+}
+
+/* Position the arrow */
+.error-text:dir(ltr)::before {
+ left: 12px
+}
+
+.error-text:dir(rtl)::before {
+ right: 12px
+}
+
+:is(input, textarea, select):-moz-ui-invalid:focus ~ .error-text {
+ visibility: visible;
+}
+
+address-form > footer > .cancel-button {
+ /* When cancel is shown (during onboarding), it should always be on the left with a space after it */
+ margin-right: auto;
+}
+
+address-form > footer > .back-button {
+ /* When back is shown (outside onboarding) we want "Back <space> Add/Save" */
+ /* Bug 1468153 may change the button ordering to match platform conventions */
+ margin-right: auto;
+}
diff --git a/browser/components/payments/res/containers/address-form.js b/browser/components/payments/res/containers/address-form.js
new file mode 100644
index 0000000000..287251ea7d
--- /dev/null
+++ b/browser/components/payments/res/containers/address-form.js
@@ -0,0 +1,447 @@
+/* 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 PaymentRequestPage from "../components/payment-request-page.js";
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+import paymentRequest from "../paymentRequest.js";
+import HandleEventMixin from "../mixins/HandleEventMixin.js";
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <address-form></address-form>
+ *
+ * Don't use document.getElementById or document.querySelector* to access form
+ * elements, use querySelector on `this` or `this.form` instead so that elements
+ * can be found before the element is connected.
+ *
+ * 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.
+ */
+
+export default class AddressForm extends HandleEventMixin(
+ PaymentStateSubscriberMixin(PaymentRequestPage)
+) {
+ constructor() {
+ super();
+
+ this.genericErrorText = document.createElement("div");
+ this.genericErrorText.setAttribute("aria-live", "polite");
+ this.genericErrorText.classList.add("page-error");
+
+ this.cancelButton = document.createElement("button");
+ this.cancelButton.className = "cancel-button";
+ this.cancelButton.addEventListener("click", this);
+
+ this.backButton = document.createElement("button");
+ this.backButton.className = "back-button";
+ this.backButton.addEventListener("click", this);
+
+ this.saveButton = document.createElement("button");
+ this.saveButton.className = "save-button primary";
+ this.saveButton.addEventListener("click", this);
+
+ this.persistCheckbox = new LabelledCheckbox();
+ this.persistCheckbox.className = "persist-checkbox";
+
+ // Combination of AddressErrors and PayerErrors as keys
+ this._errorFieldMap = {
+ addressLine: "#street-address",
+ city: "#address-level2",
+ country: "#country",
+ dependentLocality: "#address-level3",
+ email: "#email",
+ // Bug 1472283 is on file to support
+ // additional-name and family-name.
+ // XXX: For now payer name errors go on the family-name and address-errors
+ // go on the given-name so they don't overwrite each other.
+ name: "#family-name",
+ organization: "#organization",
+ phone: "#tel",
+ postalCode: "#postal-code",
+ // Bug 1472283 is on file to support
+ // additional-name and family-name.
+ recipient: "#given-name",
+ region: "#address-level1",
+ // Bug 1474905 is on file to properly support regionCode. See
+ // full note in paymentDialogWrapper.js
+ regionCode: "#address-level1",
+ };
+
+ // The markup is shared with form autofill preferences.
+ let url = "formautofill/editAddress.xhtml";
+ this.promiseReady = this._fetchMarkup(url).then(doc => {
+ this.form = doc.getElementById("form");
+ return this.form;
+ });
+ }
+
+ _fetchMarkup(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.responseType = "document";
+ xhr.addEventListener("error", reject);
+ xhr.addEventListener("load", evt => {
+ resolve(xhr.response);
+ });
+ xhr.open("GET", url);
+ xhr.send();
+ });
+ }
+
+ connectedCallback() {
+ this.promiseReady.then(form => {
+ this.body.appendChild(form);
+
+ let record = undefined;
+ this.formHandler = new EditAddress(
+ {
+ form,
+ },
+ record,
+ {
+ DEFAULT_REGION: PaymentDialogUtils.DEFAULT_REGION,
+ getFormFormat: PaymentDialogUtils.getFormFormat,
+ findAddressSelectOption: PaymentDialogUtils.findAddressSelectOption,
+ countries: PaymentDialogUtils.countries,
+ }
+ );
+
+ // The EditAddress constructor adds `input` event listeners on the same element,
+ // which update field validity. By adding our event listeners after this constructor,
+ // validity will be updated before our handlers get the event
+ this.form.addEventListener("input", this);
+ this.form.addEventListener("invalid", this);
+ this.form.addEventListener("change", this);
+
+ // The "invalid" event does not bubble and needs to be listened for on each
+ // form element.
+ for (let field of this.form.elements) {
+ field.addEventListener("invalid", this);
+ }
+
+ this.body.appendChild(this.persistCheckbox);
+ this.body.appendChild(this.genericErrorText);
+
+ this.footer.appendChild(this.cancelButton);
+ this.footer.appendChild(this.backButton);
+ this.footer.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) {
+ if (!this.id) {
+ throw new Error("AddressForm without an id");
+ }
+ let record;
+ let { page, [this.id]: addressPage } = state;
+
+ if (this.id && page && page.id !== this.id) {
+ log.debug(`${this.id}: no need to further render inactive page`);
+ return;
+ }
+
+ let editing = !!addressPage.guid;
+ this.cancelButton.textContent = this.dataset.cancelButtonLabel;
+ this.backButton.textContent = this.dataset.backButtonLabel;
+ if (editing) {
+ this.saveButton.textContent = this.dataset.updateButtonLabel;
+ } else {
+ this.saveButton.textContent = this.dataset.nextButtonLabel;
+ }
+
+ this.persistCheckbox.label = this.dataset.persistCheckboxLabel;
+ this.persistCheckbox.infoTooltip = this.dataset.persistCheckboxInfoTooltip;
+
+ this.backButton.hidden = page.onboardingWizard;
+ this.cancelButton.hidden = !page.onboardingWizard;
+
+ this.pageTitleHeading.textContent = editing
+ ? this.dataset.titleEdit
+ : this.dataset.titleAdd;
+ this.genericErrorText.textContent = page.error;
+
+ let addresses = paymentRequest.getAddresses(state);
+
+ // If an address is selected we want to edit it.
+ if (editing) {
+ record = addresses[addressPage.guid];
+ if (!record) {
+ throw new Error(
+ "Trying to edit a non-existing address: " + addressPage.guid
+ );
+ }
+ // When editing an existing record, prevent changes to persistence
+ this.persistCheckbox.hidden = true;
+ } else {
+ let {
+ saveAddressDefaultChecked,
+ } = PaymentDialogUtils.getDefaultPreferences();
+ if (typeof saveAddressDefaultChecked != "boolean") {
+ throw new Error(`Unexpected non-boolean value for saveAddressDefaultChecked from
+ PaymentDialogUtils.getDefaultPreferences(): ${typeof saveAddressDefaultChecked}`);
+ }
+ // Adding a new record: default persistence to the pref value when in a not-private session
+ this.persistCheckbox.hidden = false;
+ this.persistCheckbox.checked = state.isPrivate
+ ? false
+ : saveAddressDefaultChecked;
+ }
+
+ let selectedStateKey = this.getAttribute("selected-state-key").split("|");
+ log.debug(`${this.id}#render got selectedStateKey: ${selectedStateKey}`);
+
+ if (addressPage.addressFields) {
+ this.form.dataset.addressFields = addressPage.addressFields;
+ } else {
+ this.form.dataset.addressFields = "mailing-address tel";
+ }
+ this.formHandler.loadRecord(record);
+
+ // Add validation to some address fields
+ this.updateRequiredState();
+
+ // Show merchant errors for the appropriate address form.
+ let merchantFieldErrors = AddressForm.merchantFieldErrorsForForm(
+ state,
+ selectedStateKey
+ );
+ for (let [errorName, errorSelector] of Object.entries(
+ this._errorFieldMap
+ )) {
+ let errorText = "";
+ // Never show errors on an 'add' screen as they would be for a different address.
+ if (editing && merchantFieldErrors) {
+ if (errorName == "region" || errorName == "regionCode") {
+ errorText =
+ merchantFieldErrors.regionCode || merchantFieldErrors.region || "";
+ } else {
+ errorText = merchantFieldErrors[errorName] || "";
+ }
+ }
+ let container = this.form.querySelector(errorSelector + "-container");
+ let field = this.form.querySelector(errorSelector);
+ field.setCustomValidity(errorText);
+ let span = paymentRequest.maybeCreateFieldErrorElement(container);
+ span.textContent = errorText;
+ }
+
+ this.updateSaveButtonState();
+ }
+
+ onChange(event) {
+ if (event.target.id == "country") {
+ this.updateRequiredState();
+ }
+ this.updateSaveButtonState();
+ }
+
+ onInvalid(event) {
+ if (event.target instanceof HTMLFormElement) {
+ this.onInvalidForm(event);
+ } else {
+ this.onInvalidField(event);
+ }
+ }
+
+ onClick(evt) {
+ switch (evt.target) {
+ case this.cancelButton: {
+ paymentRequest.cancel();
+ break;
+ }
+ case this.backButton: {
+ let currentState = this.requestStore.getState();
+ const previousId = currentState.page.previousId;
+ let state = {
+ page: {
+ id: previousId || "payment-summary",
+ },
+ };
+ if (previousId) {
+ state[previousId] = Object.assign({}, currentState[previousId], {
+ preserveFieldValues: true,
+ });
+ }
+ this.requestStore.setState(state);
+ break;
+ }
+ case this.saveButton: {
+ if (this.form.checkValidity()) {
+ this.saveRecord();
+ }
+ break;
+ }
+ default: {
+ throw new Error("Unexpected click target");
+ }
+ }
+ }
+
+ onInput(event) {
+ event.target.setCustomValidity("");
+ this.updateSaveButtonState();
+ }
+
+ /**
+ * @param {Event} event - "invalid" event
+ * Note: Keep this in-sync with the equivalent version in basic-card-form.js
+ */
+ onInvalidField(event) {
+ let field = event.target;
+ let container = field.closest(`#${field.id}-container`);
+ let errorTextSpan = paymentRequest.maybeCreateFieldErrorElement(container);
+ errorTextSpan.textContent = field.validationMessage;
+ }
+
+ onInvalidForm() {
+ this.saveButton.disabled = true;
+ }
+
+ updateRequiredState() {
+ for (let field of this.form.elements) {
+ let container = field.closest(`#${field.id}-container`);
+ if (field.localName == "button" || !container) {
+ continue;
+ }
+ let span = container.querySelector(".label-text");
+ span.setAttribute(
+ "fieldRequiredSymbol",
+ this.dataset.fieldRequiredSymbol
+ );
+ container.toggleAttribute("required", field.required && !field.disabled);
+ }
+ }
+
+ updateSaveButtonState() {
+ this.saveButton.disabled = !this.form.checkValidity();
+ }
+
+ async saveRecord() {
+ let record = this.formHandler.buildFormObject();
+ let currentState = this.requestStore.getState();
+ let {
+ page,
+ tempAddresses,
+ savedBasicCards,
+ [this.id]: addressPage,
+ } = currentState;
+ let editing = !!addressPage.guid;
+
+ if (
+ editing
+ ? addressPage.guid in tempAddresses
+ : !this.persistCheckbox.checked
+ ) {
+ record.isTemporary = true;
+ }
+
+ let successStateChange;
+ const previousId = page.previousId;
+ if (page.onboardingWizard && !Object.keys(savedBasicCards).length) {
+ successStateChange = {
+ "basic-card-page": {
+ selectedStateKey: "selectedPaymentCard",
+ // Preserve field values as the user may have already edited the card
+ // page and went back to the address page to make a correction.
+ preserveFieldValues: true,
+ },
+ page: {
+ id: "basic-card-page",
+ previousId: this.id,
+ onboardingWizard: page.onboardingWizard,
+ },
+ };
+ } else {
+ successStateChange = {
+ page: {
+ id: previousId || "payment-summary",
+ onboardingWizard: page.onboardingWizard,
+ },
+ };
+ }
+
+ if (previousId) {
+ successStateChange[previousId] = Object.assign(
+ {},
+ currentState[previousId]
+ );
+ successStateChange[previousId].preserveFieldValues = true;
+ }
+
+ try {
+ let { guid } = await paymentRequest.updateAutofillRecord(
+ "addresses",
+ record,
+ addressPage.guid
+ );
+ let selectedStateKey = this.getAttribute("selected-state-key").split("|");
+
+ if (selectedStateKey.length == 1) {
+ Object.assign(successStateChange, {
+ [selectedStateKey[0]]: guid,
+ });
+ } else if (selectedStateKey.length == 2) {
+ // Need to keep properties like preserveFieldValues from getting removed.
+ let subObj = Object.assign({}, successStateChange[selectedStateKey[0]]);
+ subObj[selectedStateKey[1]] = guid;
+ Object.assign(successStateChange, {
+ [selectedStateKey[0]]: subObj,
+ });
+ } else {
+ throw new Error(
+ `selectedStateKey not supported: '${selectedStateKey}'`
+ );
+ }
+
+ this.requestStore.setState(successStateChange);
+ } catch (ex) {
+ log.warn("saveRecord: error:", ex);
+ this.requestStore.setState({
+ page: {
+ id: this.id,
+ onboardingWizard: page.onboardingWizard,
+ error: this.dataset.errorGenericSave,
+ },
+ });
+ }
+ }
+
+ /**
+ * Get the dictionary of field-specific merchant errors relevant to the
+ * specific form identified by the state key.
+ * @param {object} state The application state
+ * @param {string[]} stateKey The key in state to return address errors for.
+ * @returns {object} with keys as PaymentRequest field names and values of
+ * merchant-provided error strings.
+ */
+ static merchantFieldErrorsForForm(state, stateKey) {
+ let { paymentDetails } = state.request;
+ switch (stateKey.join("|")) {
+ case "selectedShippingAddress": {
+ return paymentDetails.shippingAddressErrors;
+ }
+ case "selectedPayerAddress": {
+ return paymentDetails.payerErrors;
+ }
+ case "basic-card-page|billingAddressGUID": {
+ // `paymentMethod` can be null.
+ return (
+ (paymentDetails.paymentMethodErrors &&
+ paymentDetails.paymentMethodErrors.billingAddress) ||
+ {}
+ );
+ }
+ default: {
+ throw new Error("Unknown selectedStateKey");
+ }
+ }
+ }
+}
+
+customElements.define("address-form", AddressForm);
diff --git a/browser/components/payments/res/containers/address-picker.js b/browser/components/payments/res/containers/address-picker.js
new file mode 100644
index 0000000000..b76a0f5d02
--- /dev/null
+++ b/browser/components/payments/res/containers/address-picker.js
@@ -0,0 +1,282 @@
+/* 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 AddressForm from "./address-form.js";
+import AddressOption from "../components/address-option.js";
+import RichPicker from "./rich-picker.js";
+import paymentRequest from "../paymentRequest.js";
+import HandleEventMixin from "../mixins/HandleEventMixin.js";
+
+/**
+ * <address-picker></address-picker>
+ * Container around add/edit links and <rich-select> with
+ * <address-option> listening to savedAddresses & tempAddresses.
+ */
+
+export default class AddressPicker extends HandleEventMixin(RichPicker) {
+ static get pickerAttributes() {
+ return ["address-fields", "break-after-nth-field", "data-field-separator"];
+ }
+
+ static get observedAttributes() {
+ return RichPicker.observedAttributes.concat(AddressPicker.pickerAttributes);
+ }
+
+ constructor() {
+ super();
+ this.dropdown.setAttribute("option-type", "address-option");
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ super.attributeChangedCallback(name, oldValue, newValue);
+ // connectedCallback may add and adjust elements & values
+ // so avoid calling render before the element is connected
+ if (
+ this.isConnected &&
+ AddressPicker.pickerAttributes.includes(name) &&
+ oldValue !== newValue
+ ) {
+ this.render(this.requestStore.getState());
+ }
+ }
+
+ get fieldNames() {
+ if (this.hasAttribute("address-fields")) {
+ let names = this.getAttribute("address-fields")
+ .trim()
+ .split(/\s+/);
+ if (names.length) {
+ return names;
+ }
+ }
+
+ return [
+ // "address-level1", // TODO: bug 1481481 - not required for some countries e.g. DE
+ "address-level2",
+ "country",
+ "name",
+ "postal-code",
+ "street-address",
+ ];
+ }
+
+ /**
+ * De-dupe and filter addresses for the given set of fields that will be visible
+ *
+ * @param {object} addresses
+ * @param {array?} fieldNames - optional list of field names that be used when
+ * de-duping and excluding entries
+ * @returns {object} filtered copy of given addresses
+ */
+ filterAddresses(addresses, fieldNames = this.fieldNames) {
+ let uniques = new Set();
+ let result = {};
+ for (let [guid, address] of Object.entries(addresses)) {
+ let addressCopy = {};
+ let isMatch = false;
+ // exclude addresses that are missing all of the requested fields
+ for (let name of fieldNames) {
+ if (address[name]) {
+ isMatch = true;
+ addressCopy[name] = address[name];
+ }
+ }
+ if (isMatch) {
+ let key = JSON.stringify(addressCopy);
+ // exclude duplicated addresses
+ if (!uniques.has(key)) {
+ uniques.add(key);
+ result[guid] = address;
+ }
+ }
+ }
+ return result;
+ }
+
+ get options() {
+ return this.dropdown.popupBox.options;
+ }
+
+ /**
+ * @param {object} state - See `PaymentsStore.setState`
+ * The value of the picker is retrieved from state store rather than the DOM
+ * @returns {string} guid
+ */
+ getCurrentValue(state) {
+ let [selectedKey, selectedLeaf] = this.selectedStateKey.split("|");
+ let guid = state[selectedKey];
+ if (selectedLeaf) {
+ guid = guid[selectedLeaf];
+ }
+ return guid;
+ }
+
+ render(state) {
+ let selectedAddressGUID = this.getCurrentValue(state) || "";
+ let addresses = paymentRequest.getAddresses(state);
+ let desiredOptions = [];
+ let filteredAddresses = this.filterAddresses(addresses, this.fieldNames);
+ for (let [guid, address] of Object.entries(filteredAddresses)) {
+ let optionEl = this.dropdown.getOptionByValue(guid);
+ if (!optionEl) {
+ optionEl = document.createElement("option");
+ optionEl.value = guid;
+ }
+
+ for (let key of AddressOption.recordAttributes) {
+ let val = address[key];
+ if (val) {
+ optionEl.setAttribute(key, val);
+ } else {
+ optionEl.removeAttribute(key);
+ }
+ }
+
+ optionEl.dataset.fieldSeparator = this.dataset.fieldSeparator;
+
+ if (this.hasAttribute("address-fields")) {
+ optionEl.setAttribute(
+ "address-fields",
+ this.getAttribute("address-fields")
+ );
+ } else {
+ optionEl.removeAttribute("address-fields");
+ }
+
+ if (this.hasAttribute("break-after-nth-field")) {
+ optionEl.setAttribute(
+ "break-after-nth-field",
+ this.getAttribute("break-after-nth-field")
+ );
+ } else {
+ optionEl.removeAttribute("break-after-nth-field");
+ }
+
+ // fieldNames getter is not used here because it returns a default array with
+ // attributes even when "address-fields" observed attribute is null.
+ let addressFields = this.getAttribute("address-fields");
+ optionEl.textContent = AddressOption.formatSingleLineLabel(
+ address,
+ addressFields
+ );
+ desiredOptions.push(optionEl);
+ }
+
+ this.dropdown.popupBox.textContent = "";
+
+ if (this._allowEmptyOption) {
+ let optionEl = document.createElement("option");
+ optionEl.value = "";
+ desiredOptions.unshift(optionEl);
+ }
+
+ for (let option of desiredOptions) {
+ this.dropdown.popupBox.appendChild(option);
+ }
+
+ // Update selectedness after the options are updated
+ this.dropdown.value = selectedAddressGUID;
+
+ if (selectedAddressGUID && selectedAddressGUID !== this.dropdown.value) {
+ throw new Error(
+ `${this.selectedStateKey} option ${selectedAddressGUID} ` +
+ `does not exist in the address picker`
+ );
+ }
+
+ super.render(state);
+ }
+
+ get selectedStateKey() {
+ return this.getAttribute("selected-state-key");
+ }
+
+ errorForSelectedOption(state) {
+ let superError = super.errorForSelectedOption(state);
+ if (superError) {
+ return superError;
+ }
+
+ if (!this.selectedOption) {
+ return "";
+ }
+
+ let merchantFieldErrors = AddressForm.merchantFieldErrorsForForm(
+ state,
+ this.selectedStateKey.split("|")
+ );
+ // TODO: errors in priority order.
+ return (
+ Object.values(merchantFieldErrors).find(msg => {
+ return typeof msg == "string" && msg.length;
+ }) || ""
+ );
+ }
+
+ onChange(event) {
+ let [selectedKey, selectedLeaf] = this.selectedStateKey.split("|");
+ if (!selectedKey) {
+ return;
+ }
+ // selectedStateKey can be a '|' delimited string indicating a path into the state object
+ // to update with the new value
+ let newState = {};
+
+ if (selectedLeaf) {
+ let currentState = this.requestStore.getState();
+ newState[selectedKey] = Object.assign({}, currentState[selectedKey], {
+ [selectedLeaf]: this.dropdown.value,
+ });
+ } else {
+ newState[selectedKey] = this.dropdown.value;
+ }
+ this.requestStore.setState(newState);
+ }
+
+ onClick({ target }) {
+ let pageId;
+ let currentState = this.requestStore.getState();
+ let nextState = {
+ page: {},
+ };
+
+ switch (this.selectedStateKey) {
+ case "selectedShippingAddress":
+ pageId = "shipping-address-page";
+ break;
+ case "selectedPayerAddress":
+ pageId = "payer-address-page";
+ break;
+ case "basic-card-page|billingAddressGUID":
+ pageId = "billing-address-page";
+ break;
+ default: {
+ throw new Error(
+ "onClick, un-matched selectedStateKey: " + this.selectedStateKey
+ );
+ }
+ }
+ nextState.page.id = pageId;
+ let addressFields = this.getAttribute("address-fields");
+ nextState[pageId] = { addressFields };
+
+ switch (target) {
+ case this.addLink: {
+ nextState[pageId].guid = null;
+ break;
+ }
+ case this.editLink: {
+ nextState[pageId].guid = this.getCurrentValue(currentState);
+ break;
+ }
+ default: {
+ throw new Error("Unexpected onClick");
+ }
+ }
+
+ this.requestStore.setState(nextState);
+ }
+}
+
+customElements.define("address-picker", AddressPicker);
diff --git a/browser/components/payments/res/containers/basic-card-form.css b/browser/components/payments/res/containers/basic-card-form.css
new file mode 100644
index 0000000000..f4a8721e03
--- /dev/null
+++ b/browser/components/payments/res/containers/basic-card-form.css
@@ -0,0 +1,43 @@
+/* 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/. */
+
+basic-card-form .editCreditCardForm {
+ /* Add the persist-checkbox row to the grid */
+ grid-template-areas:
+ "cc-number cc-exp-month cc-exp-year"
+ "cc-name cc-type cc-csc"
+ "accepted accepted accepted"
+ "persist-checkbox persist-checkbox persist-checkbox"
+ "billingAddressGUID billingAddressGUID billingAddressGUID";
+}
+
+basic-card-form csc-input {
+ display: flex;
+ flex-grow: 1;
+}
+
+basic-card-form .editCreditCardForm > accepted-cards {
+ grid-area: accepted;
+ margin: 0;
+}
+
+basic-card-form .editCreditCardForm .persist-checkbox {
+ display: flex;
+ grid-area: persist-checkbox;
+}
+
+#billingAddressGUID-container {
+ display: grid;
+}
+
+basic-card-form > footer > .cancel-button {
+ /* When cancel is shown (during onboarding), it should always be on the left with a space after it */
+ margin-right: auto;
+}
+
+basic-card-form > footer > .cancel-button[hidden] ~ .back-button {
+ /* When back is shown (outside onboarding) we want "Back <space> Add/Save" */
+ /* Bug 1468153 may change the button ordering to match platform conventions */
+ margin-right: auto;
+}
diff --git a/browser/components/payments/res/containers/basic-card-form.js b/browser/components/payments/res/containers/basic-card-form.js
new file mode 100644
index 0000000000..f71b7fc74c
--- /dev/null
+++ b/browser/components/payments/res/containers/basic-card-form.js
@@ -0,0 +1,507 @@
+/* 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 AcceptedCards from "../components/accepted-cards.js";
+import BillingAddressPicker from "./billing-address-picker.js";
+import CscInput from "../components/csc-input.js";
+import LabelledCheckbox from "../components/labelled-checkbox.js";
+import PaymentRequestPage from "../components/payment-request-page.js";
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+import paymentRequest from "../paymentRequest.js";
+import HandleEventMixin from "../mixins/HandleEventMixin.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.
+ */
+
+export default class BasicCardForm extends HandleEventMixin(
+ PaymentStateSubscriberMixin(PaymentRequestPage)
+) {
+ constructor() {
+ super();
+
+ this.genericErrorText = document.createElement("div");
+ this.genericErrorText.setAttribute("aria-live", "polite");
+ this.genericErrorText.classList.add("page-error");
+
+ this.cscInput = new CscInput({
+ useAlwaysVisiblePlaceholder: true,
+ inputId: "cc-csc",
+ });
+
+ this.persistCheckbox = new LabelledCheckbox();
+ // The persist checkbox shouldn't be part of the record which gets saved so
+ // exclude it from the form.
+ this.persistCheckbox.form = "";
+ this.persistCheckbox.className = "persist-checkbox";
+
+ this.acceptedCardsList = new AcceptedCards();
+
+ // page footer
+ this.cancelButton = document.createElement("button");
+ this.cancelButton.className = "cancel-button";
+ this.cancelButton.addEventListener("click", this);
+
+ this.backButton = document.createElement("button");
+ this.backButton.className = "back-button";
+ this.backButton.addEventListener("click", this);
+
+ this.saveButton = document.createElement("button");
+ this.saveButton.className = "save-button primary";
+ this.saveButton.addEventListener("click", this);
+
+ this.footer.append(this.cancelButton, this.backButton, this.saveButton);
+
+ // The markup is shared with form autofill preferences.
+ let url = "formautofill/editCreditCard.xhtml";
+ this.promiseReady = this._fetchMarkup(url).then(doc => {
+ this.form = doc.getElementById("form");
+ return this.form;
+ });
+ }
+
+ _fetchMarkup(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.responseType = "document";
+ xhr.addEventListener("error", reject);
+ xhr.addEventListener("load", evt => {
+ resolve(xhr.response);
+ });
+ xhr.open("GET", url);
+ xhr.send();
+ });
+ }
+
+ _upgradeBillingAddressPicker() {
+ let addressRow = this.form.querySelector(".billingAddressRow");
+ let addressPicker = (this.billingAddressPicker = new BillingAddressPicker());
+
+ // Wrap the existing <select> that the formHandler manages
+ if (addressPicker.dropdown.popupBox) {
+ addressPicker.dropdown.popupBox.remove();
+ }
+ addressPicker.dropdown.popupBox = this.form.querySelector(
+ "#billingAddressGUID"
+ );
+
+ // Hide the original label as the address picker provide its own,
+ // but we'll copy the localized textContent from it when rendering
+ addressRow.querySelector(".label-text").hidden = true;
+
+ addressPicker.dataset.addLinkLabel = this.dataset.addressAddLinkLabel;
+ addressPicker.dataset.editLinkLabel = this.dataset.addressEditLinkLabel;
+ addressPicker.dataset.fieldSeparator = this.dataset.addressFieldSeparator;
+ addressPicker.dataset.addAddressTitle = this.dataset.billingAddressTitleAdd;
+ addressPicker.dataset.editAddressTitle = this.dataset.billingAddressTitleEdit;
+ addressPicker.dataset.invalidLabel = this.dataset.invalidAddressLabel;
+ // break-after-nth-field, address-fields not needed here
+
+ // this state is only used to carry the selected guid between pages;
+ // the select#billingAddressGUID is the source of truth for the current value
+ addressPicker.setAttribute(
+ "selected-state-key",
+ "basic-card-page|billingAddressGUID"
+ );
+
+ addressPicker.addLink.addEventListener("click", this);
+ addressPicker.editLink.addEventListener("click", this);
+
+ addressRow.appendChild(addressPicker);
+ }
+
+ connectedCallback() {
+ this.promiseReady.then(form => {
+ this.body.appendChild(form);
+
+ let record = {};
+ let addresses = [];
+ this.formHandler = new EditCreditCard(
+ {
+ form,
+ },
+ record,
+ addresses,
+ {
+ isCCNumber: PaymentDialogUtils.isCCNumber,
+ getAddressLabel: PaymentDialogUtils.getAddressLabel,
+ getSupportedNetworks: PaymentDialogUtils.getCreditCardNetworks,
+ }
+ );
+
+ // The EditCreditCard constructor adds `change` and `input` event listeners on the same
+ // element, which update field validity. By adding our event listeners after this
+ // constructor, validity will be updated before our handlers get the event
+ form.addEventListener("change", this);
+ form.addEventListener("input", this);
+ form.addEventListener("invalid", this);
+
+ this._upgradeBillingAddressPicker();
+
+ // The "invalid" event does not bubble and needs to be listened for on each
+ // form element.
+ for (let field of this.form.elements) {
+ field.addEventListener("invalid", this);
+ }
+
+ // Replace the form-autofill cc-csc fields with our csc-input.
+ let cscContainer = this.form.querySelector("#cc-csc-container");
+ cscContainer.textContent = "";
+ cscContainer.appendChild(this.cscInput);
+
+ let billingAddressRow = this.form.querySelector(".billingAddressRow");
+ form.insertBefore(this.persistCheckbox, billingAddressRow);
+ form.insertBefore(this.acceptedCardsList, billingAddressRow);
+ this.body.appendChild(this.genericErrorText);
+ // Only call the connected super callback(s) once our markup is fully
+ // connected, including the shared form fetched asynchronously.
+ super.connectedCallback();
+ });
+ }
+
+ render(state) {
+ let {
+ page,
+ selectedShippingAddress,
+ "basic-card-page": basicCardPage,
+ } = state;
+
+ if (this.id && page && page.id !== this.id) {
+ log.debug(
+ `BasicCardForm: no need to further render inactive page: ${page.id}`
+ );
+ return;
+ }
+
+ if (!basicCardPage.selectedStateKey) {
+ throw new Error("A `selectedStateKey` is required");
+ }
+
+ let editing = !!basicCardPage.guid;
+ this.cancelButton.textContent = this.dataset.cancelButtonLabel;
+ this.backButton.textContent = this.dataset.backButtonLabel;
+ if (editing) {
+ this.saveButton.textContent = this.dataset.updateButtonLabel;
+ } else {
+ this.saveButton.textContent = this.dataset.nextButtonLabel;
+ }
+
+ this.cscInput.placeholder = this.dataset.cscPlaceholder;
+ this.cscInput.frontTooltip = this.dataset.cscFrontInfoTooltip;
+ this.cscInput.backTooltip = this.dataset.cscBackInfoTooltip;
+
+ // The label text from the form isn't available until render() time.
+ let labelText = this.form.querySelector(".billingAddressRow .label-text")
+ .textContent;
+ this.billingAddressPicker.setAttribute("label", labelText);
+
+ this.persistCheckbox.label = this.dataset.persistCheckboxLabel;
+ this.persistCheckbox.infoTooltip = this.dataset.persistCheckboxInfoTooltip;
+
+ this.acceptedCardsList.label = this.dataset.acceptedCardsLabel;
+
+ // The next line needs an onboarding check since we don't set previousId
+ // when navigating to add/edit directly from the summary page.
+ this.backButton.hidden = !page.previousId && page.onboardingWizard;
+ this.cancelButton.hidden = !page.onboardingWizard;
+
+ let record = {};
+ let basicCards = paymentRequest.getBasicCards(state);
+ let addresses = paymentRequest.getAddresses(state);
+
+ this.genericErrorText.textContent = page.error;
+
+ this.form.querySelector("#cc-number").disabled = editing;
+
+ // The CVV fields should be hidden and disabled when editing.
+ this.form.querySelector("#cc-csc-container").hidden = editing;
+ this.cscInput.disabled = editing;
+
+ // If a card is selected we want to edit it.
+ if (editing) {
+ this.pageTitleHeading.textContent = this.dataset.editBasicCardTitle;
+ record = basicCards[basicCardPage.guid];
+ if (!record) {
+ throw new Error(
+ "Trying to edit a non-existing card: " + basicCardPage.guid
+ );
+ }
+ // When editing an existing record, prevent changes to persistence
+ this.persistCheckbox.hidden = true;
+ } else {
+ this.pageTitleHeading.textContent = this.dataset.addBasicCardTitle;
+ // Use a currently selected shipping address as the default billing address
+ record.billingAddressGUID = basicCardPage.billingAddressGUID;
+ if (!record.billingAddressGUID && selectedShippingAddress) {
+ record.billingAddressGUID = selectedShippingAddress;
+ }
+
+ let {
+ saveCreditCardDefaultChecked,
+ } = PaymentDialogUtils.getDefaultPreferences();
+ if (typeof saveCreditCardDefaultChecked != "boolean") {
+ throw new Error(`Unexpected non-boolean value for saveCreditCardDefaultChecked from
+ PaymentDialogUtils.getDefaultPreferences(): ${typeof saveCreditCardDefaultChecked}`);
+ }
+ // Adding a new record: default persistence to pref value when in a not-private session
+ this.persistCheckbox.hidden = false;
+ if (basicCardPage.hasOwnProperty("persistCheckboxValue")) {
+ // returning to this page, use previous checked state
+ this.persistCheckbox.checked = basicCardPage.persistCheckboxValue;
+ } else {
+ this.persistCheckbox.checked = state.isPrivate
+ ? false
+ : saveCreditCardDefaultChecked;
+ }
+ }
+
+ this.formHandler.loadRecord(
+ record,
+ addresses,
+ basicCardPage.preserveFieldValues
+ );
+
+ this.form.querySelector(".billingAddressRow").hidden = false;
+
+ let billingAddressSelect = this.billingAddressPicker.dropdown;
+ if (basicCardPage.billingAddressGUID) {
+ billingAddressSelect.value = basicCardPage.billingAddressGUID;
+ } else if (!editing) {
+ if (paymentRequest.getAddresses(state)[selectedShippingAddress]) {
+ billingAddressSelect.value = selectedShippingAddress;
+ } else {
+ let firstAddressGUID = Object.keys(addresses)[0];
+ if (firstAddressGUID) {
+ // Only set the value if we have a saved address to not mark the field
+ // dirty and invalid on an add form with no saved addresses.
+ billingAddressSelect.value = firstAddressGUID;
+ }
+ }
+ }
+ // Need to recalculate the populated state since
+ // billingAddressSelect is updated after loadRecord.
+ this.formHandler.updatePopulatedState(billingAddressSelect.popupBox);
+
+ this.updateRequiredState();
+ this.updateSaveButtonState();
+ }
+
+ onChange(evt) {
+ let ccType = this.form.querySelector("#cc-type");
+ this.cscInput.setAttribute("card-type", ccType.value);
+
+ this.updateSaveButtonState();
+ }
+
+ onClick(evt) {
+ switch (evt.target) {
+ case this.cancelButton: {
+ paymentRequest.cancel();
+ break;
+ }
+ case this.billingAddressPicker.addLink:
+ case this.billingAddressPicker.editLink: {
+ // The address-picker has set state for the page to advance to, now set up the
+ // necessary state for returning to and re-rendering this page
+ let {
+ "basic-card-page": basicCardPage,
+ page,
+ } = this.requestStore.getState();
+ let nextState = {
+ page: Object.assign({}, page, {
+ previousId: "basic-card-page",
+ }),
+ "basic-card-page": {
+ preserveFieldValues: true,
+ guid: basicCardPage.guid,
+ persistCheckboxValue: this.persistCheckbox.checked,
+ selectedStateKey: basicCardPage.selectedStateKey,
+ },
+ };
+ this.requestStore.setState(nextState);
+ break;
+ }
+ case this.backButton: {
+ let currentState = this.requestStore.getState();
+ let {
+ page,
+ request,
+ "shipping-address-page": shippingAddressPage,
+ "billing-address-page": billingAddressPage,
+ "basic-card-page": basicCardPage,
+ selectedShippingAddress,
+ } = currentState;
+
+ let nextState = {
+ page: {
+ id: page.previousId || "payment-summary",
+ onboardingWizard: page.onboardingWizard,
+ },
+ };
+
+ if (page.onboardingWizard) {
+ if (request.paymentOptions.requestShipping) {
+ shippingAddressPage = Object.assign({}, shippingAddressPage, {
+ guid: selectedShippingAddress,
+ });
+ Object.assign(nextState, {
+ "shipping-address-page": shippingAddressPage,
+ });
+ } else {
+ billingAddressPage = Object.assign({}, billingAddressPage, {
+ guid: basicCardPage.billingAddressGUID,
+ });
+ Object.assign(nextState, {
+ "billing-address-page": billingAddressPage,
+ });
+ }
+
+ let basicCardPageState = Object.assign({}, basicCardPage, {
+ preserveFieldValues: true,
+ });
+ delete basicCardPageState.persistCheckboxValue;
+
+ Object.assign(nextState, {
+ "basic-card-page": basicCardPageState,
+ });
+ }
+
+ this.requestStore.setState(nextState);
+ break;
+ }
+ case this.saveButton: {
+ if (this.form.checkValidity()) {
+ this.saveRecord();
+ }
+ break;
+ }
+ default: {
+ throw new Error("Unexpected click target");
+ }
+ }
+ }
+
+ onInput(event) {
+ event.target.setCustomValidity("");
+ this.updateSaveButtonState();
+ }
+
+ onInvalid(event) {
+ if (event.target instanceof HTMLFormElement) {
+ this.onInvalidForm(event);
+ } else {
+ this.onInvalidField(event);
+ }
+ }
+
+ /**
+ * @param {Event} event - "invalid" event
+ * Note: Keep this in-sync with the equivalent version in address-form.js
+ */
+ onInvalidField(event) {
+ let field = event.target;
+ let container = field.closest(`#${field.id}-container`);
+ let errorTextSpan = paymentRequest.maybeCreateFieldErrorElement(container);
+ errorTextSpan.textContent = field.validationMessage;
+ }
+
+ onInvalidForm() {
+ this.saveButton.disabled = true;
+ }
+
+ updateSaveButtonState() {
+ const INVALID_CLASS_NAME = "invalid-selected-option";
+ let isValid =
+ this.form.checkValidity() &&
+ !this.billingAddressPicker.classList.contains(INVALID_CLASS_NAME);
+ this.saveButton.disabled = !isValid;
+ }
+
+ updateRequiredState() {
+ for (let field of this.form.elements) {
+ let container = field.closest(".container");
+ let span = container.querySelector(".label-text");
+ if (!span) {
+ // The billing address field doesn't use a label inside the field.
+ continue;
+ }
+ span.setAttribute(
+ "fieldRequiredSymbol",
+ this.dataset.fieldRequiredSymbol
+ );
+ container.toggleAttribute("required", field.required && !field.disabled);
+ }
+ }
+
+ async saveRecord() {
+ let record = this.formHandler.buildFormObject();
+ let currentState = this.requestStore.getState();
+ let { tempBasicCards, "basic-card-page": basicCardPage } = currentState;
+ let editing = !!basicCardPage.guid;
+
+ if (
+ editing
+ ? basicCardPage.guid in tempBasicCards
+ : !this.persistCheckbox.checked
+ ) {
+ record.isTemporary = true;
+ }
+
+ for (let editableFieldName of [
+ "cc-name",
+ "cc-exp-month",
+ "cc-exp-year",
+ "cc-type",
+ ]) {
+ 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 (!editing) {
+ record["cc-number"] = record["cc-number"] || "";
+ }
+
+ // Never save the CSC in storage. Storage will throw and not save the record
+ // if it is passed.
+ delete record["cc-csc"];
+
+ try {
+ let { guid } = await paymentRequest.updateAutofillRecord(
+ "creditCards",
+ record,
+ basicCardPage.guid
+ );
+ let { selectedStateKey } = currentState["basic-card-page"];
+ if (!selectedStateKey) {
+ throw new Error(
+ `state["basic-card-page"].selectedStateKey is required`
+ );
+ }
+ this.requestStore.setState({
+ page: {
+ id: "payment-summary",
+ },
+ [selectedStateKey]: guid,
+ [selectedStateKey + "SecurityCode"]: this.cscInput.value,
+ });
+ } catch (ex) {
+ log.warn("saveRecord: error:", ex);
+ this.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ error: this.dataset.errorGenericSave,
+ },
+ });
+ }
+ }
+}
+
+customElements.define("basic-card-form", BasicCardForm);
diff --git a/browser/components/payments/res/containers/billing-address-picker.js b/browser/components/payments/res/containers/billing-address-picker.js
new file mode 100644
index 0000000000..57b70a2364
--- /dev/null
+++ b/browser/components/payments/res/containers/billing-address-picker.js
@@ -0,0 +1,33 @@
+/* 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 AddressPicker from "./address-picker.js";
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <billing-address-picker></billing-address-picker>
+ * Extends AddressPicker to treat the <select>'s value as the source of truth
+ */
+
+export default class BillingAddressPicker extends AddressPicker {
+ constructor() {
+ super();
+ this._allowEmptyOption = true;
+ }
+
+ /**
+ * @param {object?} state - See `PaymentsStore.setState`
+ * The value of the picker is the child dropdown element's value
+ * @returns {string} guid
+ */
+ getCurrentValue() {
+ return this.dropdown.value;
+ }
+
+ onChange(event) {
+ this.render(this.requestStore.getState());
+ }
+}
+
+customElements.define("billing-address-picker", BillingAddressPicker);
diff --git a/browser/components/payments/res/containers/completion-error-page.js b/browser/components/payments/res/containers/completion-error-page.js
new file mode 100644
index 0000000000..9e8f7ce9f7
--- /dev/null
+++ b/browser/components/payments/res/containers/completion-error-page.js
@@ -0,0 +1,112 @@
+/* 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 HandleEventMixin from "../mixins/HandleEventMixin.js";
+import PaymentRequestPage from "../components/payment-request-page.js";
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+import paymentRequest from "../paymentRequest.js";
+
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <completion-error-page></completion-error-page>
+ *
+ * XXX: Bug 1473772 - This page isn't fully localized when used via this custom element
+ * as it will be much easier to implement and share the logic once we switch to Fluent.
+ */
+
+export default class CompletionErrorPage extends HandleEventMixin(
+ PaymentStateSubscriberMixin(PaymentRequestPage)
+) {
+ constructor() {
+ super();
+
+ this.classList.add("error-page");
+ this.suggestionHeading = document.createElement("p");
+ this.body.append(this.suggestionHeading);
+ this.suggestionsList = document.createElement("ul");
+ this.suggestions = [];
+ this.body.append(this.suggestionsList);
+
+ this.brandingSpan = document.createElement("span");
+ this.brandingSpan.classList.add("branding");
+ this.footer.appendChild(this.brandingSpan);
+
+ this.doneButton = document.createElement("button");
+ this.doneButton.classList.add("done-button", "primary");
+ this.doneButton.addEventListener("click", this);
+
+ this.footer.appendChild(this.doneButton);
+ }
+
+ render(state) {
+ let { page } = state;
+
+ if (this.id && page && page.id !== this.id) {
+ log.debug(
+ `CompletionErrorPage: no need to further render inactive page: ${page.id}`
+ );
+ return;
+ }
+
+ let { request } = this.requestStore.getState();
+ let { displayHost } = request.topLevelPrincipal.URI;
+ for (let key of [
+ "pageTitle",
+ "suggestion-heading",
+ "suggestion-1",
+ "suggestion-2",
+ "suggestion-3",
+ ]) {
+ if (this.dataset[key] && displayHost) {
+ this.dataset[key] = this.dataset[key].replace(
+ "**host-name**",
+ displayHost
+ );
+ }
+ }
+
+ this.pageTitleHeading.textContent = this.dataset.pageTitle;
+ this.suggestionHeading.textContent = this.dataset.suggestionHeading;
+ this.brandingSpan.textContent = this.dataset.brandingLabel;
+ this.doneButton.textContent = this.dataset.doneButtonLabel;
+
+ this.suggestionsList.textContent = "";
+ if (this.dataset["suggestion-1"]) {
+ this.suggestions[0] = this.dataset["suggestion-1"];
+ }
+ if (this.dataset["suggestion-2"]) {
+ this.suggestions[1] = this.dataset["suggestion-2"];
+ }
+ if (this.dataset["suggestion-3"]) {
+ this.suggestions[2] = this.dataset["suggestion-3"];
+ }
+
+ let suggestionsFragment = document.createDocumentFragment();
+ for (let suggestionText of this.suggestions) {
+ let listNode = document.createElement("li");
+ listNode.textContent = suggestionText;
+ suggestionsFragment.appendChild(listNode);
+ }
+ this.suggestionsList.appendChild(suggestionsFragment);
+ }
+
+ onClick(event) {
+ switch (event.target) {
+ case this.doneButton: {
+ this.onDoneButtonClick(event);
+ break;
+ }
+ default: {
+ throw new Error("Unexpected click target");
+ }
+ }
+ }
+
+ onDoneButtonClick(event) {
+ paymentRequest.closeDialog();
+ }
+}
+
+customElements.define("completion-error-page", CompletionErrorPage);
diff --git a/browser/components/payments/res/containers/cvv-hint-image-back.svg b/browser/components/payments/res/containers/cvv-hint-image-back.svg
new file mode 100644
index 0000000000..1e9f4ddb10
--- /dev/null
+++ b/browser/components/payments/res/containers/cvv-hint-image-back.svg
@@ -0,0 +1,27 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="46" height="27" version="1.1">
+ <defs>
+ <circle id="a" cx="10" cy="10" r="10"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd" stroke="none" stroke-width="1">
+ <path fill="#A1C6FF" d="M37 6.2a10.046 10.046 0 0 0 -2 -0.2c-5.523 0 -10 4.477 -10 10a9.983 9.983 0 0 0 3.999 8h-27.999a1 1 0 0 1 -1 -1v-22a1 1 0 0 1 1 -1h35a1 1 0 0 1 1 1v5.2zm-18 7.8c3.314 0 6 -1.567 6 -3.5s-2.686 -3.5 -6 -3.5 -6 1.567 -6 3.5 2.686 3.5 6 3.5z"/>
+ <path fill="#5F5F5F" d="M2 17h9v2h-9v-2zm0 -15h33v3h-33v-3zm0 18h15v2h-15v-2zm10 -3h13v2h-13v-2z"/>
+ <g transform="translate(25 6)">
+ <mask id="b" fill="#fff">
+ <use xlink:href="#a"/>
+ </mask>
+ <use stroke="#FFF" stroke-width="1.5" xlink:href="#a"/>
+ <g mask="url(#b)">
+ <g transform="translate(-77 -31)">
+ <rect width="99.39" height="69.141" x="0" y="0" fill="#A1C6FF" fill-rule="evenodd" rx="1"/>
+ <path fill="#5F5F5F" fill-rule="evenodd" d="M79 46h17v6h-17z"/>
+ <text fill="none" font-family="sans-serif" font-size="6">
+ <tspan x="80" y="42" fill="#5F5F5F">1234</tspan>
+ </text>
+ </g>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/browser/components/payments/res/containers/cvv-hint-image-front.svg b/browser/components/payments/res/containers/cvv-hint-image-front.svg
new file mode 100644
index 0000000000..5a758870a1
--- /dev/null
+++ b/browser/components/payments/res/containers/cvv-hint-image-front.svg
@@ -0,0 +1,25 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="45" height="27" version="1.1">
+ <defs>
+ <circle id="a" cx="10" cy="10" r="10"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd" stroke="none" stroke-width="1">
+ <path fill="#62A0FF" d="M37 6.458a9.996 9.996 0 0 0 -3 -0.458c-3.701 0 -6.933 2.011 -8.662 5h-22.338v5h21a9.983 9.983 0 0 0 3.999 8h-26.999a1 1 0 0 1 -1 -1v-22a1 1 0 0 1 1 -1h35a1 1 0 0 1 1 1v5.458z"/>
+ <path fill="#5F5F5F" d="M37 6.458a9.996 9.996 0 0 0 -3 -0.458 9.97 9.97 0 0 0 -7.141 3h-26.859v-6h37v3.458z"/>
+ <g transform="translate(24 6)">
+ <mask id="b" fill="#fff">
+ <use xlink:href="#a"/>
+ </mask>
+ <use stroke="#FFF" stroke-width="1.5" xlink:href="#a"/>
+ <g mask="url(#b)">
+ <path fill="#62A0FF" fill-rule="evenodd" d="M-41.923 -15.615h64.476a1 1 0 0 1 1 1v44.244a1 1 0 0 1 -1 1h-64.476a1 1 0 0 1 -1 -1v-44.244a1 1 0 0 1 1 -1zm2.923 19.615v9h55v-9h-55z"/>
+ <path fill="#5F5F5F" fill-rule="evenodd" d="M-43 -10h66v12h-66z"/>
+ <text fill="none" font-family="sans-serif" font-size="6" transform="translate(-43.923 -15.615)">
+ <tspan x="47.676" y="26.104" fill="#5F5F5F">123</tspan>
+ </text>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/browser/components/payments/res/containers/error-page.css b/browser/components/payments/res/containers/error-page.css
new file mode 100644
index 0000000000..bd4bec96b5
--- /dev/null
+++ b/browser/components/payments/res/containers/error-page.css
@@ -0,0 +1,42 @@
+/* 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/. */
+
+.error-page.illustrated > .page-body {
+ display: flex;
+ justify-content: center;
+ min-height: 160px;
+ background-position: left center;
+ background-repeat: no-repeat;
+ background-size: 160px;
+ padding-inline-start: 160px;
+}
+
+.error-page.illustrated > .page-body:dir(rtl) {
+ background-position: right center;
+}
+
+.error-page.illustrated > .page-body > h2 {
+ background: none;
+ padding-inline-start: 0;
+ margin-inline-start: 0;
+ font-weight: lighter;
+ font-size: 2rem;
+}
+
+.error-page.illustrated > .page-body > p {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+.error-page.illustrated > .page-body > ul {
+ margin-top: .5rem;
+}
+
+.error-page#completion-timeout-error > .page-body {
+ background-image: url("./timeout.svg");
+}
+
+.error-page#completion-fail-error > .page-body {
+ background-image: url("./warning.svg");
+}
diff --git a/browser/components/payments/res/containers/order-details.css b/browser/components/payments/res/containers/order-details.css
new file mode 100644
index 0000000000..beadeb3b88
--- /dev/null
+++ b/browser/components/payments/res/containers/order-details.css
@@ -0,0 +1,55 @@
+/* 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/. */
+
+order-details {
+ display: grid;
+ grid-template-columns: 20% auto 10rem;
+ grid-gap: 1em;
+ margin: 1px 4vw;
+}
+
+order-details > ul {
+ list-style-type: none;
+ margin: 1em 0;
+ padding: 0;
+ display: contents;
+}
+
+order-details payment-details-item {
+ margin: 1px 0;
+ display: contents;
+}
+payment-details-item .label {
+ grid-column-start: 1;
+ grid-column-end: 3;
+}
+payment-details-item currency-amount {
+ grid-column-start: 3;
+ grid-column-end: 4;
+}
+
+order-details .footer-items-list:not(:empty):before {
+ border: 1px solid GrayText;
+ display: block;
+ content: "";
+ grid-column-start: 1;
+ grid-column-end: 4;
+}
+
+order-details > .details-total {
+ margin: 1px 0;
+ display: contents;
+}
+
+.details-total > .label {
+ margin: 0;
+ font-size: large;
+ grid-column-start: 2;
+ grid-column-end: 3;
+ text-align: end;
+}
+.details-total > currency-amount {
+ font-size: large;
+ text-align: end;
+}
diff --git a/browser/components/payments/res/containers/order-details.js b/browser/components/payments/res/containers/order-details.js
new file mode 100644
index 0000000000..a458c14784
--- /dev/null
+++ b/browser/components/payments/res/containers/order-details.js
@@ -0,0 +1,143 @@
+/* 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/. */
+
+// <currency-amount> is used in the <template>
+import "../components/currency-amount.js";
+import PaymentDetailsItem from "../components/payment-details-item.js";
+import paymentRequest from "../paymentRequest.js";
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+
+/**
+ * <order-details></order-details>
+ */
+
+export default class OrderDetails extends PaymentStateSubscriberMixin(
+ HTMLElement
+) {
+ connectedCallback() {
+ if (!this._contents) {
+ let template = document.getElementById("order-details-template");
+ let contents = (this._contents = document.importNode(
+ template.content,
+ true
+ ));
+
+ this._mainItemsList = contents.querySelector(".main-list");
+ this._footerItemsList = contents.querySelector(".footer-items-list");
+ this._totalAmount = contents.querySelector(
+ ".details-total > currency-amount"
+ );
+
+ this.appendChild(this._contents);
+ }
+ super.connectedCallback();
+ }
+
+ get mainItemsList() {
+ return this._mainItemsList;
+ }
+
+ get footerItemsList() {
+ return this._footerItemsList;
+ }
+
+ get totalAmountElem() {
+ return this._totalAmount;
+ }
+
+ static _emptyList(listEl) {
+ while (listEl.lastChild) {
+ listEl.removeChild(listEl.lastChild);
+ }
+ }
+
+ static _populateList(listEl, items) {
+ let fragment = document.createDocumentFragment();
+ for (let item of items) {
+ let row = new PaymentDetailsItem();
+ row.label = item.label;
+ row.amountValue = item.amount.value;
+ row.amountCurrency = item.amount.currency;
+ fragment.appendChild(row);
+ }
+ listEl.appendChild(fragment);
+ return listEl;
+ }
+
+ _getAdditionalDisplayItems(state) {
+ let methodId = state.selectedPaymentCard;
+ let modifier = paymentRequest.getModifierForPaymentMethod(state, methodId);
+ if (modifier && modifier.additionalDisplayItems) {
+ return modifier.additionalDisplayItems;
+ }
+ return [];
+ }
+
+ render(state) {
+ let totalItem = paymentRequest.getTotalItem(state);
+
+ OrderDetails._emptyList(this.mainItemsList);
+ OrderDetails._emptyList(this.footerItemsList);
+
+ let mainItems = OrderDetails._getMainListItems(state);
+ if (mainItems.length) {
+ OrderDetails._populateList(this.mainItemsList, mainItems);
+ }
+
+ let footerItems = OrderDetails._getFooterListItems(state);
+ if (footerItems.length) {
+ OrderDetails._populateList(this.footerItemsList, footerItems);
+ }
+
+ this.totalAmountElem.value = totalItem.amount.value;
+ this.totalAmountElem.currency = totalItem.amount.currency;
+ }
+
+ /**
+ * Determine if a display item should belong in the footer list.
+ * This uses the proposed "type" property, tracked at:
+ * https://github.com/w3c/payment-request/issues/163
+ *
+ * @param {object} item - Data representing a PaymentItem
+ * @returns {boolean}
+ */
+ static isFooterItem(item) {
+ return item.type == "tax";
+ }
+
+ static _getMainListItems(state) {
+ let request = state.request;
+ let items = request.paymentDetails.displayItems;
+ if (Array.isArray(items) && items.length) {
+ let predicate = item => !OrderDetails.isFooterItem(item);
+ return request.paymentDetails.displayItems.filter(predicate);
+ }
+ return [];
+ }
+
+ static _getFooterListItems(state) {
+ let request = state.request;
+ let items = request.paymentDetails.displayItems;
+ let footerItems = [];
+ let methodId = state.selectedPaymentCard;
+ if (methodId) {
+ let modifier = paymentRequest.getModifierForPaymentMethod(
+ state,
+ methodId
+ );
+ if (modifier && Array.isArray(modifier.additionalDisplayItems)) {
+ footerItems.push(...modifier.additionalDisplayItems);
+ }
+ }
+ if (Array.isArray(items) && items.length) {
+ let predicate = OrderDetails.isFooterItem;
+ footerItems.push(
+ ...request.paymentDetails.displayItems.filter(predicate)
+ );
+ }
+ return footerItems;
+ }
+}
+
+customElements.define("order-details", OrderDetails);
diff --git a/browser/components/payments/res/containers/payment-dialog.js b/browser/components/payments/res/containers/payment-dialog.js
new file mode 100644
index 0000000000..7bf094e267
--- /dev/null
+++ b/browser/components/payments/res/containers/payment-dialog.js
@@ -0,0 +1,593 @@
+/* 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 HandleEventMixin from "../mixins/HandleEventMixin.js";
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+import paymentRequest from "../paymentRequest.js";
+
+import "../components/currency-amount.js";
+import "../components/payment-request-page.js";
+import "../components/accepted-cards.js";
+import "./address-picker.js";
+import "./address-form.js";
+import "./basic-card-form.js";
+import "./completion-error-page.js";
+import "./order-details.js";
+import "./payment-method-picker.js";
+import "./shipping-option-picker.js";
+
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <payment-dialog></payment-dialog>
+ *
+ * Warning: Do not import this module from any other module as it will import
+ * everything else (see above) and ruin element independence. This can stop
+ * being exported once tests stop depending on it.
+ */
+
+export default class PaymentDialog extends HandleEventMixin(
+ PaymentStateSubscriberMixin(HTMLElement)
+) {
+ constructor() {
+ super();
+ this._template = document.getElementById("payment-dialog-template");
+ this._cachedState = {};
+ }
+
+ connectedCallback() {
+ let contents = document.importNode(this._template.content, true);
+ this._hostNameEl = contents.querySelector("#host-name");
+
+ this._cancelButton = contents.querySelector("#cancel");
+ this._cancelButton.addEventListener("click", this.cancelRequest);
+
+ this._payButton = contents.querySelector("#pay");
+ this._payButton.addEventListener("click", this);
+
+ this._viewAllButton = contents.querySelector("#view-all");
+ this._viewAllButton.addEventListener("click", this);
+
+ this._mainContainer = contents.getElementById("main-container");
+ this._orderDetailsOverlay = contents.querySelector(
+ "#order-details-overlay"
+ );
+
+ this._shippingAddressPicker = contents.querySelector(
+ "address-picker.shipping-related"
+ );
+ this._shippingOptionPicker = contents.querySelector(
+ "shipping-option-picker"
+ );
+ this._shippingRelatedEls = contents.querySelectorAll(".shipping-related");
+ this._payerRelatedEls = contents.querySelectorAll(".payer-related");
+ this._payerAddressPicker = contents.querySelector(
+ "address-picker.payer-related"
+ );
+ this._paymentMethodPicker = contents.querySelector("payment-method-picker");
+ this._acceptedCardsList = contents.querySelector("accepted-cards");
+ this._manageText = contents.querySelector(".manage-text");
+ this._manageText.addEventListener("click", this);
+
+ this._header = contents.querySelector("header");
+
+ this._errorText = contents.querySelector("header > .page-error");
+
+ this._disabledOverlay = contents.getElementById("disabled-overlay");
+
+ this.appendChild(contents);
+
+ super.connectedCallback();
+ }
+
+ disconnectedCallback() {
+ this._cancelButton.removeEventListener("click", this.cancelRequest);
+ this._payButton.removeEventListener("click", this.pay);
+ this._viewAllButton.removeEventListener("click", this);
+ super.disconnectedCallback();
+ }
+
+ onClick(event) {
+ switch (event.currentTarget) {
+ case this._viewAllButton:
+ let orderDetailsShowing = !this.requestStore.getState()
+ .orderDetailsShowing;
+ this.requestStore.setState({ orderDetailsShowing });
+ break;
+ case this._payButton:
+ this.pay();
+ break;
+ case this._manageText:
+ if (event.target instanceof HTMLAnchorElement) {
+ this.openPreferences(event);
+ }
+ break;
+ }
+ }
+
+ openPreferences(event) {
+ paymentRequest.openPreferences();
+ event.preventDefault();
+ }
+
+ cancelRequest() {
+ paymentRequest.cancel();
+ }
+
+ pay() {
+ let state = this.requestStore.getState();
+ let {
+ selectedPayerAddress,
+ selectedPaymentCard,
+ selectedPaymentCardSecurityCode,
+ selectedShippingAddress,
+ } = state;
+
+ let data = {
+ selectedPaymentCardGUID: selectedPaymentCard,
+ selectedPaymentCardSecurityCode,
+ };
+
+ data.selectedShippingAddressGUID = state.request.paymentOptions
+ .requestShipping
+ ? selectedShippingAddress
+ : null;
+
+ data.selectedPayerAddressGUID = this._isPayerRequested(
+ state.request.paymentOptions
+ )
+ ? selectedPayerAddress
+ : null;
+
+ paymentRequest.pay(data);
+ }
+
+ /**
+ * Called when the selectedShippingAddress or its properties are changed.
+ * @param {string} shippingAddressGUID
+ */
+ changeShippingAddress(shippingAddressGUID) {
+ // Clear shipping address merchant errors when the shipping address changes.
+ let request = Object.assign({}, this.requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails);
+ request.paymentDetails.shippingAddressErrors = {};
+ this.requestStore.setState({ request });
+
+ paymentRequest.changeShippingAddress({
+ shippingAddressGUID,
+ });
+ }
+
+ changeShippingOption(optionID) {
+ paymentRequest.changeShippingOption({
+ optionID,
+ });
+ }
+
+ /**
+ * Called when the selectedPaymentCard or its relevant properties or billingAddress are changed.
+ * @param {string} selectedPaymentCardBillingAddressGUID
+ */
+ changePaymentMethod(selectedPaymentCardBillingAddressGUID) {
+ // Clear paymentMethod merchant errors when the paymentMethod or billingAddress changes.
+ let request = Object.assign({}, this.requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails);
+ request.paymentDetails.paymentMethodErrors = null;
+ this.requestStore.setState({ request });
+
+ paymentRequest.changePaymentMethod({
+ selectedPaymentCardBillingAddressGUID,
+ });
+ }
+
+ /**
+ * Called when the selectedPayerAddress or its relevant properties are changed.
+ * @param {string} payerAddressGUID
+ */
+ changePayerAddress(payerAddressGUID) {
+ // Clear payer address merchant errors when the payer address changes.
+ let request = Object.assign({}, this.requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails);
+ request.paymentDetails.payerErrors = {};
+ this.requestStore.setState({ request });
+
+ paymentRequest.changePayerAddress({
+ payerAddressGUID,
+ });
+ }
+
+ _isPayerRequested(paymentOptions) {
+ return (
+ paymentOptions.requestPayerName ||
+ paymentOptions.requestPayerEmail ||
+ paymentOptions.requestPayerPhone
+ );
+ }
+
+ _getAdditionalDisplayItems(state) {
+ let methodId = state.selectedPaymentCard;
+ let modifier = paymentRequest.getModifierForPaymentMethod(state, methodId);
+ if (modifier && modifier.additionalDisplayItems) {
+ return modifier.additionalDisplayItems;
+ }
+ return [];
+ }
+
+ _updateCompleteStatus(state) {
+ let { completeStatus } = state.request;
+ switch (completeStatus) {
+ case "fail":
+ case "timeout":
+ case "unknown":
+ state.page = {
+ id: `completion-${completeStatus}-error`,
+ };
+ state.changesPrevented = false;
+ break;
+ case "": {
+ // When we get a DOM update for an updateWith() or retry() the completeStatus
+ // is "" when we need to show non-final screens. Don't set the page as we
+ // may be on a form instead of payment-summary
+ state.changesPrevented = false;
+ break;
+ }
+ }
+ return state;
+ }
+
+ /**
+ * Set some state from the privileged parent process.
+ * Other elements that need to set state should use their own `this.requestStore.setState`
+ * method provided by the `PaymentStateSubscriberMixin`.
+ *
+ * @param {object} state - See `PaymentsStore.setState`
+ */
+ // eslint-disable-next-line complexity
+ async setStateFromParent(state) {
+ let oldAddresses = paymentRequest.getAddresses(
+ this.requestStore.getState()
+ );
+ let oldBasicCards = paymentRequest.getBasicCards(
+ this.requestStore.getState()
+ );
+ if (state.request) {
+ state = this._updateCompleteStatus(state);
+ }
+ this.requestStore.setState(state);
+
+ // Check if any foreign-key constraints were invalidated.
+ state = this.requestStore.getState();
+ let {
+ selectedPayerAddress,
+ selectedPaymentCard,
+ selectedShippingAddress,
+ selectedShippingOption,
+ } = state;
+ let addresses = paymentRequest.getAddresses(state);
+ let { paymentOptions } = state.request;
+
+ if (paymentOptions.requestShipping) {
+ let shippingOptions = state.request.paymentDetails.shippingOptions;
+ let shippingAddress =
+ selectedShippingAddress && addresses[selectedShippingAddress];
+ let oldShippingAddress =
+ selectedShippingAddress && oldAddresses[selectedShippingAddress];
+
+ // Ensure `selectedShippingAddress` never refers to a deleted address.
+ // We also compare address timestamps to notify about changes
+ // made outside the payments UI.
+ if (shippingAddress) {
+ // invalidate the cached value if the address was modified
+ if (
+ oldShippingAddress &&
+ shippingAddress.guid == oldShippingAddress.guid &&
+ shippingAddress.timeLastModified !=
+ oldShippingAddress.timeLastModified
+ ) {
+ delete this._cachedState.selectedShippingAddress;
+ }
+ } else if (selectedShippingAddress !== null) {
+ // null out the `selectedShippingAddress` property if it is undefined,
+ // or if the address it pointed to was removed from storage.
+ log.debug("resetting invalid/deleted shipping address");
+ this.requestStore.setState({
+ selectedShippingAddress: null,
+ });
+ }
+
+ // Ensure `selectedShippingOption` never refers to a deleted shipping option and
+ // matches the merchant's selected option if the user hasn't made a choice.
+ if (
+ shippingOptions &&
+ (!selectedShippingOption ||
+ !shippingOptions.find(opt => opt.id == selectedShippingOption))
+ ) {
+ this._cachedState.selectedShippingOption = selectedShippingOption;
+ this.requestStore.setState({
+ // Use the DOM's computed selected shipping option:
+ selectedShippingOption: state.request.shippingOption,
+ });
+ }
+ }
+
+ let basicCards = paymentRequest.getBasicCards(state);
+ let oldPaymentMethod =
+ selectedPaymentCard && oldBasicCards[selectedPaymentCard];
+ let paymentMethod = selectedPaymentCard && basicCards[selectedPaymentCard];
+ if (
+ oldPaymentMethod &&
+ paymentMethod.guid == oldPaymentMethod.guid &&
+ paymentMethod.timeLastModified != oldPaymentMethod.timeLastModified
+ ) {
+ delete this._cachedState.selectedPaymentCard;
+ } else {
+ // Changes to the billing address record don't change the `timeLastModified`
+ // on the card record so we have to check for changes to the address separately.
+
+ let billingAddressGUID =
+ paymentMethod && paymentMethod.billingAddressGUID;
+ let billingAddress = billingAddressGUID && addresses[billingAddressGUID];
+ let oldBillingAddress =
+ billingAddressGUID && oldAddresses[billingAddressGUID];
+
+ if (
+ oldBillingAddress &&
+ billingAddress &&
+ billingAddress.timeLastModified != oldBillingAddress.timeLastModified
+ ) {
+ delete this._cachedState.selectedPaymentCard;
+ }
+ }
+
+ // Ensure `selectedPaymentCard` never refers to a deleted payment card.
+ if (selectedPaymentCard && !basicCards[selectedPaymentCard]) {
+ this.requestStore.setState({
+ selectedPaymentCard: null,
+ selectedPaymentCardSecurityCode: null,
+ });
+ }
+
+ if (this._isPayerRequested(state.request.paymentOptions)) {
+ let payerAddress =
+ selectedPayerAddress && addresses[selectedPayerAddress];
+ let oldPayerAddress =
+ selectedPayerAddress && oldAddresses[selectedPayerAddress];
+
+ if (
+ oldPayerAddress &&
+ payerAddress &&
+ ((paymentOptions.requestPayerName &&
+ payerAddress.name != oldPayerAddress.name) ||
+ (paymentOptions.requestPayerEmail &&
+ payerAddress.email != oldPayerAddress.email) ||
+ (paymentOptions.requestPayerPhone &&
+ payerAddress.tel != oldPayerAddress.tel))
+ ) {
+ // invalidate the cached value if the payer address fields were modified
+ delete this._cachedState.selectedPayerAddress;
+ }
+
+ // Ensure `selectedPayerAddress` never refers to a deleted address and refers
+ // to an address if one exists.
+ if (!addresses[selectedPayerAddress]) {
+ this.requestStore.setState({
+ selectedPayerAddress: Object.keys(addresses)[0] || null,
+ });
+ }
+ }
+ }
+
+ _renderPayButton(state) {
+ let completeStatus = state.request.completeStatus;
+ switch (completeStatus) {
+ case "processing":
+ case "success":
+ case "unknown": {
+ this._payButton.disabled = true;
+ this._payButton.textContent = this._payButton.dataset[
+ completeStatus + "Label"
+ ];
+ break;
+ }
+ case "": {
+ // initial/default state
+ this._payButton.textContent = this._payButton.dataset.label;
+ const INVALID_CLASS_NAME = "invalid-selected-option";
+ this._payButton.disabled =
+ (state.request.paymentOptions.requestShipping &&
+ (!this._shippingAddressPicker.selectedOption ||
+ this._shippingAddressPicker.classList.contains(
+ INVALID_CLASS_NAME
+ ) ||
+ !this._shippingOptionPicker.selectedOption)) ||
+ (this._isPayerRequested(state.request.paymentOptions) &&
+ (!this._payerAddressPicker.selectedOption ||
+ this._payerAddressPicker.classList.contains(
+ INVALID_CLASS_NAME
+ ))) ||
+ !this._paymentMethodPicker.securityCodeInput.isValid ||
+ !this._paymentMethodPicker.selectedOption ||
+ this._paymentMethodPicker.classList.contains(INVALID_CLASS_NAME) ||
+ state.changesPrevented;
+ break;
+ }
+ case "fail":
+ case "timeout": {
+ // pay button is hidden in fail/timeout states.
+ this._payButton.textContent = this._payButton.dataset.label;
+ this._payButton.disabled = true;
+ break;
+ }
+ default: {
+ throw new Error(`Invalid completeStatus: ${completeStatus}`);
+ }
+ }
+ }
+
+ _renderPayerFields(state) {
+ let paymentOptions = state.request.paymentOptions;
+ let payerRequested = this._isPayerRequested(paymentOptions);
+ let payerAddressForm = this.querySelector(
+ "address-form[selected-state-key='selectedPayerAddress']"
+ );
+
+ for (let element of this._payerRelatedEls) {
+ element.hidden = !payerRequested;
+ }
+
+ if (payerRequested) {
+ let fieldNames = new Set();
+ if (paymentOptions.requestPayerName) {
+ fieldNames.add("name");
+ }
+ if (paymentOptions.requestPayerEmail) {
+ fieldNames.add("email");
+ }
+ if (paymentOptions.requestPayerPhone) {
+ fieldNames.add("tel");
+ }
+ let addressFields = [...fieldNames].join(" ");
+ this._payerAddressPicker.setAttribute("address-fields", addressFields);
+ if (payerAddressForm.form) {
+ payerAddressForm.form.dataset.extraRequiredFields = addressFields;
+ }
+
+ // For the payer picker we want to have a line break after the name field (#1)
+ // if all three fields are requested.
+ if (fieldNames.size == 3) {
+ this._payerAddressPicker.setAttribute("break-after-nth-field", 1);
+ } else {
+ this._payerAddressPicker.removeAttribute("break-after-nth-field");
+ }
+ } else {
+ this._payerAddressPicker.removeAttribute("address-fields");
+ }
+ }
+
+ stateChangeCallback(state) {
+ super.stateChangeCallback(state);
+
+ // Don't dispatch change events for initial selectedShipping* changes at initialization
+ // if requestShipping is false.
+ if (state.request.paymentOptions.requestShipping) {
+ if (
+ state.selectedShippingAddress !=
+ this._cachedState.selectedShippingAddress
+ ) {
+ this.changeShippingAddress(state.selectedShippingAddress);
+ }
+
+ if (
+ state.selectedShippingOption != this._cachedState.selectedShippingOption
+ ) {
+ this.changeShippingOption(state.selectedShippingOption);
+ }
+ }
+
+ let selectedPaymentCard = state.selectedPaymentCard;
+ let basicCards = paymentRequest.getBasicCards(state);
+ let billingAddressGUID = (basicCards[selectedPaymentCard] || {})
+ .billingAddressGUID;
+ if (
+ selectedPaymentCard != this._cachedState.selectedPaymentCard &&
+ billingAddressGUID
+ ) {
+ // Update _cachedState to prevent an infinite loop when changePaymentMethod updates state.
+ this._cachedState.selectedPaymentCard = state.selectedPaymentCard;
+ this.changePaymentMethod(billingAddressGUID);
+ }
+
+ if (this._isPayerRequested(state.request.paymentOptions)) {
+ if (
+ state.selectedPayerAddress != this._cachedState.selectedPayerAddress
+ ) {
+ this.changePayerAddress(state.selectedPayerAddress);
+ }
+ }
+
+ this._cachedState.selectedShippingAddress = state.selectedShippingAddress;
+ this._cachedState.selectedShippingOption = state.selectedShippingOption;
+ this._cachedState.selectedPayerAddress = state.selectedPayerAddress;
+ }
+
+ render(state) {
+ let request = state.request;
+ let paymentDetails = request.paymentDetails;
+ this._hostNameEl.textContent = request.topLevelPrincipal.URI.displayHost;
+
+ let displayItems = request.paymentDetails.displayItems || [];
+ let additionalItems = this._getAdditionalDisplayItems(state);
+ this._viewAllButton.hidden =
+ !displayItems.length && !additionalItems.length;
+
+ let shippingType = state.request.paymentOptions.shippingType || "shipping";
+ let addressPickerLabel = this._shippingAddressPicker.dataset[
+ shippingType + "AddressLabel"
+ ];
+ this._shippingAddressPicker.setAttribute("label", addressPickerLabel);
+ let optionPickerLabel = this._shippingOptionPicker.dataset[
+ shippingType + "OptionsLabel"
+ ];
+ this._shippingOptionPicker.setAttribute("label", optionPickerLabel);
+
+ let shippingAddressForm = this.querySelector(
+ "address-form[selected-state-key='selectedShippingAddress']"
+ );
+ shippingAddressForm.dataset.titleAdd = this.dataset[
+ shippingType + "AddressTitleAdd"
+ ];
+ shippingAddressForm.dataset.titleEdit = this.dataset[
+ shippingType + "AddressTitleEdit"
+ ];
+
+ let totalItem = paymentRequest.getTotalItem(state);
+ let totalAmountEl = this.querySelector("#total > currency-amount");
+ totalAmountEl.value = totalItem.amount.value;
+ totalAmountEl.currency = totalItem.amount.currency;
+
+ // Show the total header on the address and basic card pages only during
+ // on-boarding(FTU) and on the payment summary page.
+ this._header.hidden =
+ !state.page.onboardingWizard && state.page.id != "payment-summary";
+
+ this._orderDetailsOverlay.hidden = !state.orderDetailsShowing;
+ let genericError = "";
+ if (
+ this._shippingAddressPicker.selectedOption &&
+ (!request.paymentDetails.shippingOptions ||
+ !request.paymentDetails.shippingOptions.length)
+ ) {
+ genericError = this._errorText.dataset[shippingType + "GenericError"];
+ }
+ this._errorText.textContent = paymentDetails.error || genericError;
+
+ let paymentOptions = request.paymentOptions;
+ for (let element of this._shippingRelatedEls) {
+ element.hidden = !paymentOptions.requestShipping;
+ }
+
+ this._renderPayerFields(state);
+
+ let isMac = /mac/i.test(navigator.platform);
+ for (let manageTextEl of this._manageText.children) {
+ manageTextEl.hidden = manageTextEl.dataset.os == "mac" ? !isMac : isMac;
+ let link = manageTextEl.querySelector("a");
+ // The href is only set to be exposed to accessibility tools so users know what will open.
+ // The actual opening happens from the click event listener.
+ link.href = "about:preferences#privacy-form-autofill";
+ }
+
+ this._renderPayButton(state);
+
+ for (let page of this._mainContainer.querySelectorAll(":scope > .page")) {
+ page.hidden = state.page.id != page.id;
+ }
+
+ this.toggleAttribute("changes-prevented", state.changesPrevented);
+ this.setAttribute("complete-status", request.completeStatus);
+ this._disabledOverlay.hidden = !state.changesPrevented;
+ }
+}
+
+customElements.define("payment-dialog", PaymentDialog);
diff --git a/browser/components/payments/res/containers/payment-method-picker.js b/browser/components/payments/res/containers/payment-method-picker.js
new file mode 100644
index 0000000000..3693d352a5
--- /dev/null
+++ b/browser/components/payments/res/containers/payment-method-picker.js
@@ -0,0 +1,199 @@
+/* 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 CscInput from "../components/csc-input.js";
+import HandleEventMixin from "../mixins/HandleEventMixin.js";
+import RichPicker from "./rich-picker.js";
+import paymentRequest from "../paymentRequest.js";
+
+/* import-globals-from ../unprivileged-fallbacks.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 HandleEventMixin(RichPicker) {
+ constructor() {
+ super();
+ this.dropdown.setAttribute("option-type", "basic-card-option");
+ this.securityCodeInput = new CscInput();
+ this.securityCodeInput.className = "security-code-container";
+ this.securityCodeInput.placeholder = this.dataset.cscPlaceholder;
+ this.securityCodeInput.backTooltip = this.dataset.cscBackTooltip;
+ this.securityCodeInput.frontTooltip = this.dataset.cscFrontTooltip;
+ this.securityCodeInput.addEventListener("change", this);
+ this.securityCodeInput.addEventListener("input", this);
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.dropdown.after(this.securityCodeInput);
+ }
+
+ get fieldNames() {
+ let fieldNames = [...BasicCardOption.recordAttributes];
+ return fieldNames;
+ }
+
+ render(state) {
+ let basicCards = paymentRequest.getBasicCards(state);
+ let desiredOptions = [];
+ for (let [guid, basicCard] of Object.entries(basicCards)) {
+ let optionEl = this.dropdown.getOptionByValue(guid);
+ if (!optionEl) {
+ optionEl = document.createElement("option");
+ optionEl.value = guid;
+ }
+
+ for (let key of BasicCardOption.recordAttributes) {
+ let val = basicCard[key];
+ if (val) {
+ optionEl.setAttribute(key, val);
+ } else {
+ optionEl.removeAttribute(key);
+ }
+ }
+
+ optionEl.textContent = BasicCardOption.formatSingleLineLabel(basicCard);
+ desiredOptions.push(optionEl);
+ }
+
+ this.dropdown.popupBox.textContent = "";
+ for (let option of desiredOptions) {
+ this.dropdown.popupBox.appendChild(option);
+ }
+
+ // Update selectedness after the options are updated
+ let selectedPaymentCardGUID = state[this.selectedStateKey];
+ if (selectedPaymentCardGUID) {
+ this.dropdown.value = selectedPaymentCardGUID;
+
+ if (selectedPaymentCardGUID !== this.dropdown.value) {
+ throw new Error(
+ `The option ${selectedPaymentCardGUID} ` +
+ `does not exist in the payment method picker`
+ );
+ }
+ } else {
+ this.dropdown.value = "";
+ }
+
+ let securityCodeState = state[this.selectedStateKey + "SecurityCode"];
+ if (
+ securityCodeState &&
+ securityCodeState != this.securityCodeInput.value
+ ) {
+ this.securityCodeInput.defaultValue = securityCodeState;
+ }
+
+ let selectedCardType =
+ (basicCards[selectedPaymentCardGUID] &&
+ basicCards[selectedPaymentCardGUID]["cc-type"]) ||
+ "";
+ this.securityCodeInput.cardType = selectedCardType;
+
+ super.render(state);
+ }
+
+ errorForSelectedOption(state) {
+ let superError = super.errorForSelectedOption(state);
+ if (superError) {
+ return superError;
+ }
+ let selectedOption = this.selectedOption;
+ if (!selectedOption) {
+ return "";
+ }
+
+ let basicCardMethod = state.request.paymentMethods.find(
+ method => method.supportedMethods == "basic-card"
+ );
+ let merchantNetworks =
+ basicCardMethod &&
+ basicCardMethod.data &&
+ basicCardMethod.data.supportedNetworks;
+ let acceptedNetworks =
+ merchantNetworks || PaymentDialogUtils.getCreditCardNetworks();
+ let selectedCard = paymentRequest.getBasicCards(state)[
+ selectedOption.value
+ ];
+ let isSupported =
+ selectedCard["cc-type"] &&
+ acceptedNetworks.includes(selectedCard["cc-type"]);
+ return isSupported ? "" : this.dataset.invalidLabel;
+ }
+
+ get selectedStateKey() {
+ return this.getAttribute("selected-state-key");
+ }
+
+ onInput(event) {
+ this.onInputOrChange(event);
+ }
+
+ onChange(event) {
+ this.onInputOrChange(event);
+ }
+
+ onInputOrChange({ currentTarget }) {
+ let selectedKey = this.selectedStateKey;
+ let stateChange = {};
+
+ if (!selectedKey) {
+ return;
+ }
+
+ switch (currentTarget) {
+ case this.dropdown: {
+ stateChange[selectedKey] = this.dropdown.value;
+ break;
+ }
+ case this.securityCodeInput: {
+ stateChange[
+ selectedKey + "SecurityCode"
+ ] = this.securityCodeInput.value;
+ break;
+ }
+ default: {
+ return;
+ }
+ }
+
+ this.requestStore.setState(stateChange);
+ }
+
+ onClick({ target }) {
+ let nextState = {
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ selectedStateKey: this.selectedStateKey,
+ },
+ };
+
+ switch (target) {
+ case this.addLink: {
+ nextState["basic-card-page"].guid = null;
+ break;
+ }
+ case this.editLink: {
+ let state = this.requestStore.getState();
+ let selectedPaymentCardGUID = state[this.selectedStateKey];
+ nextState["basic-card-page"].guid = selectedPaymentCardGUID;
+ break;
+ }
+ default: {
+ throw new Error("Unexpected onClick");
+ }
+ }
+
+ this.requestStore.setState(nextState);
+ }
+}
+
+customElements.define("payment-method-picker", PaymentMethodPicker);
diff --git a/browser/components/payments/res/containers/rich-picker.css b/browser/components/payments/res/containers/rich-picker.css
new file mode 100644
index 0000000000..7b232518d0
--- /dev/null
+++ b/browser/components/payments/res/containers/rich-picker.css
@@ -0,0 +1,83 @@
+/* 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/. */
+
+.rich-picker {
+ display: grid;
+ grid-template-columns: 5fr auto auto;
+ grid-template-areas:
+ "label edit add"
+ "dropdown dropdown dropdown"
+ "invalid invalid invalid";
+ padding-top: 8px;
+}
+
+.rich-picker > label {
+ color: #0c0c0d;
+ font-weight: 700;
+ grid-area: label;
+}
+
+.rich-picker > .add-link,
+.rich-picker > .edit-link {
+ padding: 0 8px;
+}
+
+.rich-picker > .add-link {
+ grid-area: add;
+}
+
+.rich-picker > .edit-link {
+ grid-area: edit;
+ border-inline-end: 1px solid #0C0C0D33;
+}
+
+.rich-picker > rich-select {
+ grid-area: dropdown;
+}
+
+.invalid-selected-option > rich-select > select {
+ border: 1px solid #c70011;
+}
+
+.rich-picker > .invalid-label {
+ grid-area: invalid;
+ font-weight: normal;
+ color: #c70011;
+}
+
+:not(.invalid-selected-option) > .invalid-label {
+ display: none;
+}
+
+/* Payment Method Picker */
+payment-method-picker.rich-picker {
+ grid-template-columns: 20fr 1fr auto auto;
+ grid-template-areas:
+ "label spacer edit add"
+ "dropdown csc csc csc"
+ "invalid invalid invalid invalid";
+}
+
+.security-code-container {
+ display: flex;
+ flex-grow: 1;
+ grid-area: csc;
+ margin: 10px 0; /* Has to be same as rich-select */
+}
+
+.rich-picker .security-code {
+ border: 1px solid #0C0C0D33;
+ /* Underlap the 1px border from common.css */
+ margin-inline-start: -1px;
+ flex-grow: 1;
+ padding: 8px;
+}
+
+.rich-picker .security-code:-moz-ui-invalid,
+.rich-picker .security-code:focus {
+ /* So the error outline and focus ring appear above the adjacent dropdown when appropriate. */
+ /* We don't want to always be on top or we will cover the error outline or focus outline from the
+ dropdown. */
+ z-index: 1;
+}
diff --git a/browser/components/payments/res/containers/rich-picker.js b/browser/components/payments/res/containers/rich-picker.js
new file mode 100644
index 0000000000..3752f67942
--- /dev/null
+++ b/browser/components/payments/res/containers/rich-picker.js
@@ -0,0 +1,114 @@
+/* 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 PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+import RichSelect from "../components/rich-select.js";
+
+export default class RichPicker extends PaymentStateSubscriberMixin(
+ HTMLElement
+) {
+ static get observedAttributes() {
+ return ["label"];
+ }
+
+ constructor() {
+ super();
+ this.classList.add("rich-picker");
+
+ this.dropdown = new RichSelect();
+ this.dropdown.addEventListener("change", this);
+
+ this.labelElement = document.createElement("label");
+
+ this.addLink = document.createElement("a");
+ this.addLink.className = "add-link";
+ this.addLink.href = "javascript:void(0)";
+ this.addLink.addEventListener("click", this);
+
+ this.editLink = document.createElement("a");
+ this.editLink.className = "edit-link";
+ this.editLink.href = "javascript:void(0)";
+ this.editLink.addEventListener("click", this);
+
+ this.invalidLabel = document.createElement("label");
+ this.invalidLabel.className = "invalid-label";
+ }
+
+ connectedCallback() {
+ if (!this.dropdown.popupBox.id) {
+ this.dropdown.popupBox.id =
+ "select-" + Math.floor(Math.random() * 1000000);
+ }
+ this.labelElement.setAttribute("for", this.dropdown.popupBox.id);
+ this.invalidLabel.setAttribute("for", this.dropdown.popupBox.id);
+
+ // The document order, by default, controls tab order so keep that in mind if changing this.
+ this.appendChild(this.labelElement);
+ this.appendChild(this.dropdown);
+ this.appendChild(this.editLink);
+ this.appendChild(this.addLink);
+ this.appendChild(this.invalidLabel);
+ super.connectedCallback();
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ if (name == "label") {
+ this.labelElement.textContent = newValue;
+ }
+ }
+
+ render(state) {
+ this.editLink.hidden = !this.dropdown.value;
+
+ let errorText = this.errorForSelectedOption(state);
+ this.classList.toggle("invalid-selected-option", !!errorText);
+ this.invalidLabel.textContent = errorText;
+ this.addLink.textContent = this.dataset.addLinkLabel;
+ this.editLink.textContent = this.dataset.editLinkLabel;
+ }
+
+ get selectedOption() {
+ return this.dropdown.selectedOption;
+ }
+
+ get selectedRichOption() {
+ return this.dropdown.selectedRichOption;
+ }
+
+ get requiredFields() {
+ return this.selectedOption ? this.selectedOption.requiredFields || [] : [];
+ }
+
+ get fieldNames() {
+ return [];
+ }
+
+ /**
+ * @param {object} state Application state
+ * @returns {string} Containing an error message for the picker or "" for no error.
+ */
+ errorForSelectedOption(state) {
+ if (!this.selectedOption) {
+ return "";
+ }
+ if (!this.dataset.invalidLabel) {
+ throw new Error("data-invalid-label is required");
+ }
+ return this.missingFieldsOfSelectedOption().length
+ ? this.dataset.invalidLabel
+ : "";
+ }
+
+ missingFieldsOfSelectedOption() {
+ let selectedOption = this.selectedOption;
+ if (!selectedOption) {
+ return [];
+ }
+
+ let fieldNames = this.selectedRichOption.requiredFields || [];
+
+ // Return all field names that are empty or missing from the option.
+ return fieldNames.filter(name => !selectedOption.getAttribute(name));
+ }
+}
diff --git a/browser/components/payments/res/containers/shipping-option-picker.js b/browser/components/payments/res/containers/shipping-option-picker.js
new file mode 100644
index 0000000000..01a97f1ac4
--- /dev/null
+++ b/browser/components/payments/res/containers/shipping-option-picker.js
@@ -0,0 +1,72 @@
+/* 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 RichPicker from "./rich-picker.js";
+import ShippingOption from "../components/shipping-option.js";
+import HandleEventMixin from "../mixins/HandleEventMixin.js";
+
+/**
+ * <shipping-option-picker></shipping-option-picker>
+ * Container around <rich-select> with
+ * <option> listening to shippingOptions.
+ */
+
+export default class ShippingOptionPicker extends HandleEventMixin(RichPicker) {
+ constructor() {
+ super();
+ this.dropdown.setAttribute("option-type", "shipping-option");
+ }
+
+ render(state) {
+ this.addLink.hidden = true;
+ this.editLink.hidden = true;
+
+ // If requestShipping is true but paymentDetails.shippingOptions isn't defined
+ // then use an empty array as a fallback.
+ let shippingOptions = state.request.paymentDetails.shippingOptions || [];
+ let desiredOptions = [];
+ for (let option of shippingOptions) {
+ let optionEl = this.dropdown.getOptionByValue(option.id);
+ if (!optionEl) {
+ optionEl = document.createElement("option");
+ optionEl.value = option.id;
+ }
+
+ optionEl.setAttribute("label", option.label);
+ optionEl.setAttribute("amount-currency", option.amount.currency);
+ optionEl.setAttribute("amount-value", option.amount.value);
+
+ optionEl.textContent = ShippingOption.formatSingleLineLabel(option);
+ desiredOptions.push(optionEl);
+ }
+
+ this.dropdown.popupBox.textContent = "";
+ for (let option of desiredOptions) {
+ this.dropdown.popupBox.appendChild(option);
+ }
+
+ // Update selectedness after the options are updated
+ let selectedShippingOption = state.selectedShippingOption;
+ this.dropdown.value = selectedShippingOption;
+
+ if (
+ selectedShippingOption &&
+ selectedShippingOption !== this.dropdown.popupBox.value
+ ) {
+ throw new Error(
+ `The option ${selectedShippingOption} ` +
+ `does not exist in the shipping option picker`
+ );
+ }
+ }
+
+ onChange(event) {
+ let selectedOptionId = this.dropdown.value;
+ this.requestStore.setState({
+ selectedShippingOption: selectedOptionId,
+ });
+ }
+}
+
+customElements.define("shipping-option-picker", ShippingOptionPicker);
diff --git a/browser/components/payments/res/containers/timeout.svg b/browser/components/payments/res/containers/timeout.svg
new file mode 100644
index 0000000000..a9c96ababd
--- /dev/null
+++ b/browser/components/payments/res/containers/timeout.svg
@@ -0,0 +1,84 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="183" height="159" version="1.1">
+ <defs>
+ <linearGradient id="a" x1="-58.737%" x2="177.192%" y1="-3.847%" y2="112.985%">
+ <stop offset="0%" stop-color="#CCFBFF"/>
+ <stop offset="100%" stop-color="#C9E4FF"/>
+ </linearGradient>
+ <linearGradient id="b" x1="-62.081%" x2="144.194%" y1="-14.656%" y2="104.338%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#008EA4"/>
+ </linearGradient>
+ <linearGradient id="c" x1="-93.784%" x2="130.325%" y1="-512.631%" y2="364.268%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="d" x1="-632.1%" x2="878.563%" y1="-1341.641%" y2="1656.303%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="e" x1="-145.225%" x2="166.502%" y1="-230.02%" y2="260.392%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="f" x1="-16.485%" x2="216.233%" y1="-373.776%" y2="1088.73%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="g" x1="-13.212%" x2="220.924%" y1="-398.815%" y2="1263.59%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="h" x1="56.524%" x2="154.312%" y1="90.974%" y2="705.12%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#008EA4"/>
+ </linearGradient>
+ <linearGradient id="i" x1="-2629.906%" x2="2946.182%" y1="-2629.905%" y2="2946.183%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="j" x1="-2788.189%" x2="2787.9%" y1="-2788.189%" y2="2787.9%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="k" x1="-2975.533%" x2="2600.555%" y1="-2975.533%" y2="2600.555%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="l" x1="-2968.553%" x2="2607.535%" y1="-2968.552%" y2="2607.537%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="m" x1="-47.612%" x2="165.227%" y1="-4.394%" y2="113.507%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ </defs>
+ <g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1">
+ <path fill="#EAEAEE" d="M41.548 94.855h110.678a1 1 0 1 0 0 -2h-110.678a1 1 0 0 0 0 2zm14.952 -5h27.764a0.5 0.5 0 1 0 0 -1h-27.765a0.5 0.5 0 1 0 0 1zm-35.386 8.869a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 1 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 1 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 1 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 1 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 1 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 1 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 0 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 0 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 0 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 0 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 0 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 0 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 0 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5z"/>
+ <path fill="#FFF" d="M1.474 63.811h25.423s-7.955 -17.777 8.932 -20.076c15.062 -2.05 21.014 13.427 21.014 13.427s1.786 -8.93 10.743 -7.221c8.832 1.684 15.353 15.889 15.353 15.889h22.137"/>
+ <path fill="#EAEAEE" d="M105.51 61.633h-6.544a0.588 0.588 0 1 1 0 -1.176h6.545a0.588 0.588 0 0 1 0 1.176zm-17.132 0h-1.176a0.588 0.588 0 1 1 0 -1.176h1.176a0.588 0.588 0 1 1 0 1.176zm-61.046 -0.712h-1.893a0.588 0.588 0 0 1 0 -1.176h1.023a24.94 24.94 0 0 1 -0.269 -0.756 0.588 0.588 0 0 1 1.115 -0.377 18.812 18.812 0 0 0 0.56 1.48 0.588 0.588 0 0 1 -0.536 0.829zm-11.305 0h-14.117a0.588 0.588 0 1 1 0 -1.176h14.117a0.588 0.588 0 0 1 0 1.176zm66.928 -0.064a0.588 0.588 0 0 1 -0.514 -0.301 44.273 44.273 0 0 0 -1.828 -2.974 0.588 0.588 0 1 1 0.977 -0.654 45.65 45.65 0 0 1 1.878 3.053 0.588 0.588 0 0 1 -0.513 0.876zm-57.304 -6.042a0.588 0.588 0 0 1 -0.582 -0.507 20.77 20.77 0 0 1 -0.134 -1.205 0.588 0.588 0 0 1 1.173 -0.094 19.39 19.39 0 0 0 0.126 1.136 0.588 0.588 0 0 1 -0.583 0.67zm30.647 -2.594a0.587 0.587 0 0 1 -0.519 -0.31 26.496 26.496 0 0 0 -0.57 -1.001 0.588 0.588 0 1 1 1.01 -0.605 27.467 27.467 0 0 1 0.596 1.048 0.588 0.588 0 0 1 -0.517 0.868zm18.676 -1.503a0.586 0.586 0 0 1 -0.383 -0.143 14.722 14.722 0 0 0 -6.68 -3.535 8.578 8.578 0 0 0 -5.628 0.61 0.588 0.588 0 1 1 -0.54 -1.046 9.747 9.747 0 0 1 6.389 -0.72 15.855 15.855 0 0 1 7.225 3.8 0.588 0.588 0 0 1 -0.383 1.034zm-22.019 -3.335a0.587 0.587 0 0 1 -0.443 -0.202 21.943 21.943 0 0 0 -2.45 -2.41 0.588 0.588 0 0 1 0.755 -0.903 23.254 23.254 0 0 1 2.582 2.54 0.588 0.588 0 0 1 -0.444 0.975zm-24.258 -3.43a0.589 0.589 0 0 1 -0.395 -1.025 14.421 14.421 0 0 1 7.882 -3.255 19.345 19.345 0 0 1 5.96 0.08 0.588 0.588 0 1 1 -0.203 1.158 18.263 18.263 0 0 0 -5.597 -0.073 13.284 13.284 0 0 0 -7.253 2.963 0.59 0.59 0 0 1 -0.394 0.152z"/>
+ <path fill="#FFF" d="M106.242 66.149h-104.771a1.176 1.176 0 1 1 0 -2.353h104.771a1.176 1.176 0 0 1 0 2.353zm1.978 -51.759h14.187s-4.44 -9.92 4.985 -11.203c8.404 -1.144 11.726 7.493 11.726 7.493s0.907 -4.089 5.995 -4.03c4.756 0.055 8.38 7.914 8.568 8.867h12.353"/>
+ <path fill="#EAEAEE" d="M122.823 12.8h-14.167a0.59 0.59 0 1 1 0 -1.18h14.167a0.59 0.59 0 0 1 0 1.18zm43.647 -0.184h-0.545a0.59 0.59 0 1 1 0 -1.18h0.545a0.59 0.59 0 1 1 0 1.18zm-5.267 0h-3.542a0.59 0.59 0 1 1 0 -1.18h3.542a0.59 0.59 0 1 1 0 1.18zm-21.648 -3.527a0.614 0.614 0 0 1 -0.553 -0.384l-0.088 -0.207a0.59 0.59 0 0 1 0.23 -0.74 5.483 5.483 0 0 1 5.186 -4 7.111 7.111 0 0 1 1.33 0.132 10.622 10.622 0 0 1 5.267 3.045 0.59 0.59 0 0 1 -0.822 0.848 9.484 9.484 0 0 0 -4.666 -2.733 5.935 5.935 0 0 0 -1.109 -0.111c-3.402 0 -4.166 3.527 -4.196 3.678a0.592 0.592 0 0 1 -0.579 0.472zm-16.422 -5.27a0.59 0.59 0 0 1 -0.45 -0.973 6.799 6.799 0 0 1 3.236 -2.026 0.59 0.59 0 1 1 0.351 1.127 5.632 5.632 0 0 0 -2.688 1.665 0.589 0.589 0 0 1 -0.45 0.208zm8.784 -1.987a0.601 0.601 0 0 1 -0.156 -0.02 9.055 9.055 0 0 0 -1.083 -0.225 0.59 0.59 0 0 1 -0.5 -0.668 0.6 0.6 0 0 1 0.668 -0.5 10.36 10.36 0 0 1 1.224 0.253 0.59 0.59 0 0 1 -0.153 1.16z"/>
+ <path fill="#FFF" d="M166.948 16.76h-58.468a1.18 1.18 0 0 1 0 -2.36h58.468a1.18 1.18 0 0 1 0 2.36z"/>
+ <ellipse cx="99.859" cy="152.41" fill="#EAEAEE" rx="30" ry="5.802"/>
+ <path fill="#F9F9FA" d="M116.421 66.559c-4.126 4.126 -9.051 9.488 -9.656 16.465 0.605 6.977 5.53 12.339 9.656 16.465a26.199 26.199 0 0 1 9.126 19.9v11.242h-52.517v-11.242a26.199 26.199 0 0 1 9.126 -19.9c4.126 -4.126 9.051 -9.488 9.656 -16.465 -0.605 -6.977 -5.53 -12.339 -9.656 -16.465a26.199 26.199 0 0 1 -9.126 -19.9v-11.369h52.517v11.369a26.199 26.199 0 0 1 -9.126 19.9z"/>
+ <path fill="url(#a)" d="M35.304 21.239c-3.349 4.126 -8.666 10.808 -9.157 17.785 0.491 6.977 5.808 13.659 9.157 17.785a25.765 25.765 0 0 1 7.408 18.58v11.242h-42.627v-11.242a25.765 25.765 0 0 1 7.407 -18.58c3.35 -4.126 8.667 -10.808 9.157 -17.785 -0.49 -6.977 -5.808 -13.659 -9.157 -17.785a25.765 25.765 0 0 1 -7.407 -18.58v-2.13a95.816 95.816 0 0 0 22.369 2.64c12.527 0 20.258 -2.64 20.258 -2.64v2.13a25.765 25.765 0 0 1 -7.408 18.58z" transform="translate(78 44)"/>
+ <path fill="url(#b)" d="M55.548 6.222h-52.518a2.932 2.932 0 0 1 0 -5.864h52.518a2.932 2.932 0 0 1 0 5.864zm2.932 92.409a2.932 2.932 0 0 0 -2.932 -2.932h-52.518a2.932 2.932 0 0 0 0 5.863h52.518a2.932 2.932 0 0 0 2.932 -2.931z" transform="translate(70 32)"/>
+ <path fill="url(#c)" d="M129.028 130.684c0.334 5 -13.767 7.667 -29.749 7.667 -15.981 0 -28.937 -3.358 -28.937 -7.5 0 -4.143 12.956 -7.5 28.937 -7.5 15.982 0 29.474 3.2 29.75 7.333z"/>
+ <path fill="#F9F9FA" d="M125.654 127.755c0 2.185 -11.233 5.596 -25.792 5.596 -14.56 0 -26.932 -3.41 -26.932 -5.596 0 -2.185 11.802 -3.956 26.362 -3.956 14.56 0 26.362 1.771 26.362 3.956z"/>
+ <path fill="url(#d)" d="M95.233 76.435s1.978 4.121 4.286 4.286c2.307 0.165 4.43 -4.388 4.43 -4.388l-8.716 0.102z"/>
+ <path fill="url(#e)" d="M99.288 102.48c7.502 0 21.137 23.169 21.137 23.169s-1.442 3.702 -20.563 3.702c-19.122 0 -21.71 -3.702 -21.71 -3.702s13.763 -23.17 21.136 -23.17z"/>
+ <path fill="url(#f)" d="M127.852 37.313c0 2.185 -12.049 5.038 -27.657 5.038s-28.864 -2.853 -28.864 -5.038 12.653 -3.956 28.26 -3.956c15.609 0 28.261 1.771 28.261 3.956z"/>
+ <ellipse cx="99.42" cy="33.357" fill="url(#g)" rx="28.089" ry="3.956"/>
+ <ellipse cx="99.603" cy="76.333" fill="url(#h)" rx="4.346" ry="1"/>
+ <circle cx="99.393" cy="84.717" r="1.181" fill="url(#i)"/>
+ <circle cx="98.996" cy="92.589" r="1.181" fill="url(#j)"/>
+ <circle cx="98.145" cy="102.288" r="1.181" fill="url(#k)"/>
+ <circle cx="101.388" cy="98.715" r="1.181" fill="url(#l)"/>
+ <path fill="#F9F9FA" d="M84.628 53.363a1.833 1.833 0 0 1 -0.264 -0.026l-0.237 -0.08a41.18 41.18 0 0 0 -0.238 -0.118 2.238 2.238 0 0 1 -0.197 -0.172 1.325 1.325 0 0 1 -0.382 -0.922 1.359 1.359 0 0 1 0.105 -0.515 1.297 1.297 0 0 1 0.277 -0.422 1.012 1.012 0 0 1 0.197 -0.158 2.196 2.196 0 0 1 0.238 -0.132 1.55 1.55 0 0 1 0.237 -0.066 1.331 1.331 0 0 1 1.2 0.356 1.297 1.297 0 0 1 0.278 0.422 1.169 1.169 0 0 1 0.105 0.515 1.329 1.329 0 0 1 -1.319 1.318zm-0.221 69.007a1.319 1.319 0 0 1 -1.318 -1.282c-0.227 -8.439 2.93 -13.501 3.064 -13.712a1.319 1.319 0 0 1 2.227 1.413c-0.057 0.092 -2.858 4.684 -2.653 12.227a1.32 1.32 0 0 1 -1.283 1.355h-0.037zm8.793 -54.507a1.319 1.319 0 0 1 -1.002 -0.46 102.116 102.116 0 0 1 -6.712 -9.614 1.319 1.319 0 0 1 2.24 -1.39 101.694 101.694 0 0 0 6.476 9.287 1.32 1.32 0 0 1 -1.002 2.177z"/>
+ <path fill="url(#m)" d="M127.377 126.244v-6.856a28.144 28.144 0 0 0 -9.656 -21.217c-4.074 -4.092 -8.47 -8.976 -9.052 -15.1 0.582 -6.218 4.978 -11.103 9.026 -15.17a28.118 28.118 0 0 0 9.682 -21.242v-6.529a3.426 3.426 0 0 0 2.27 -2.456 4.696 4.696 0 0 0 -0.888 -5.862c-3.257 -4.026 -24.92 -4.23 -29.23 -4.23 -10.758 0 -24.656 0.91 -28.356 3.436a4.777 4.777 0 0 0 -2.674 4.272 4.71 4.71 0 0 0 2.19 3.98 4.54 4.54 0 0 0 0.73 0.528v6.86a28.09 28.09 0 0 0 9.657 21.218c4.074 4.093 8.47 8.98 9.051 15.101 -0.582 6.215 -4.977 11.102 -9.025 15.169a28.149 28.149 0 0 0 -9.68 21.317l-0.003 6.78a4.741 4.741 0 0 0 -2.92 4.387 4.455 4.455 0 0 0 0.319 1.685c1.459 6.64 27.596 6.83 30.571 6.83 2.951 0 28.885 -0.189 30.526 -6.661a4.734 4.734 0 0 0 -2.538 -6.24zm0.365 5.42l-0.041 0.132c-0.48 3.01 -15.028 5.032 -28.312 5.032 -16.33 0 -28.03 -2.665 -28.315 -5.058l-0.04 -0.145a2.426 2.426 0 0 1 2.205 -3.426h0.5v-8.81a25.817 25.817 0 0 1 8.946 -19.548c4.404 -4.42 9.154 -9.727 9.763 -16.86 -0.609 -7.047 -5.359 -12.354 -9.79 -16.8a25.792 25.792 0 0 1 -8.92 -19.522v-8.348l-0.318 -0.124a3.457 3.457 0 0 1 -1.232 -0.69l-0.117 -0.091a2.422 2.422 0 0 1 0.19 -4.336l0.094 -0.056c2.392 -1.774 14.074 -3.113 27.175 -3.113 15.231 0 26.482 1.747 27.434 3.379l0.115 0.134a2.396 2.396 0 0 1 0.333 3.42l-0.165 0.2 0.094 0.32c-0.057 0.126 -0.362 0.534 -1.938 1.048l-0.345 0.112v8.145a25.817 25.817 0 0 1 -8.946 19.547c-4.404 4.421 -9.154 9.728 -9.763 16.86 0.609 7.048 5.359 12.354 9.79 16.8a25.813 25.813 0 0 1 8.92 19.564v8.769h0.5a2.42 2.42 0 0 1 2.183 3.464z"/>
+ </g>
+</svg>
diff --git a/browser/components/payments/res/containers/warning.svg b/browser/components/payments/res/containers/warning.svg
new file mode 100644
index 0000000000..0b25be0f91
--- /dev/null
+++ b/browser/components/payments/res/containers/warning.svg
@@ -0,0 +1,32 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="162" height="147" version="1.1">
+ <defs>
+ <linearGradient id="a" x1="-5.18%" x2="85.305%" y1="13.831%" y2="117.402%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="b" x1="-26.306%" x2="110.545%" y1="-9.375%" y2="148.477%">
+ <stop offset="0%" stop-color="#CCFBFF"/>
+ <stop offset="100%" stop-color="#C9E4FF"/>
+ </linearGradient>
+ <linearGradient id="c" x1="-335.989%" x2="397.876%" y1="-66.454%" y2="146.763%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ </defs>
+ <g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1">
+ <path fill="#EAEAEE" d="M20.53 83.44h110.678a1 1 0 1 0 0 -2h-110.679a1 1 0 0 0 0 2zm14.951 -5h27.765a0.5 0.5 0 1 0 0 -1h-27.765a0.5 0.5 0 1 0 0 1zm-35.385 8.87a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 1 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 1 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 1 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 1 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 1 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 1 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 1 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 1 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 0 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 0 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 0 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 0 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 0 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5z"/>
+ <path fill="#FFF" d="M36.206 58.897h25.423s-7.955 -17.777 8.932 -20.077c15.062 -2.05 21.014 13.427 21.014 13.427s1.786 -8.93 10.743 -7.221c8.832 1.684 15.352 15.889 15.352 15.889h22.137"/>
+ <path fill="#EAEAEE" d="M140.243 56.719h-6.545a0.588 0.588 0 0 1 0 -1.177h6.545a0.588 0.588 0 0 1 0 1.177zm-17.133 0h-1.177a0.588 0.588 0 0 1 0 -1.177h1.177a0.588 0.588 0 0 1 0 1.177zm-61.047 -0.713h-1.892a0.588 0.588 0 1 1 0 -1.176h1.023a24.95 24.95 0 0 1 -0.27 -0.756 0.588 0.588 0 0 1 1.115 -0.377 18.81 18.81 0 0 0 0.562 1.48 0.588 0.588 0 0 1 -0.538 0.83zm-11.304 0h-14.118a0.588 0.588 0 1 1 0 -1.176h14.118a0.588 0.588 0 1 1 0 1.176zm66.928 -0.064a0.588 0.588 0 0 1 -0.515 -0.301 44.263 44.263 0 0 0 -1.827 -2.973 0.588 0.588 0 1 1 0.977 -0.655 45.639 45.639 0 0 1 1.878 3.053 0.588 0.588 0 0 1 -0.513 0.876zm-57.304 -6.042a0.588 0.588 0 0 1 -0.582 -0.507 20.768 20.768 0 0 1 -0.134 -1.205 0.588 0.588 0 0 1 1.173 -0.094 19.388 19.388 0 0 0 0.126 1.136 0.588 0.588 0 0 1 -0.583 0.67zm30.646 -2.594a0.587 0.587 0 0 1 -0.518 -0.31 26.496 26.496 0 0 0 -0.57 -1.001 0.588 0.588 0 1 1 1.01 -0.604 27.467 27.467 0 0 1 0.595 1.047 0.588 0.588 0 0 1 -0.517 0.868zm18.677 -1.503a0.586 0.586 0 0 1 -0.383 -0.142 14.722 14.722 0 0 0 -6.68 -3.536 8.578 8.578 0 0 0 -5.628 0.61 0.588 0.588 0 1 1 -0.54 -1.046 9.747 9.747 0 0 1 6.389 -0.72 15.855 15.855 0 0 1 7.225 3.8 0.588 0.588 0 0 1 -0.383 1.034zm-22.019 -3.335a0.587 0.587 0 0 1 -0.443 -0.201 21.943 21.943 0 0 0 -2.45 -2.41 0.588 0.588 0 0 1 0.755 -0.904 23.255 23.255 0 0 1 2.582 2.54 0.588 0.588 0 0 1 -0.444 0.975zm-24.259 -3.43a0.589 0.589 0 0 1 -0.394 -1.025 14.421 14.421 0 0 1 7.882 -3.254 19.345 19.345 0 0 1 5.959 0.079 0.588 0.588 0 1 1 -0.202 1.158 18.263 18.263 0 0 0 -5.598 -0.072 13.284 13.284 0 0 0 -7.252 2.963 0.59 0.59 0 0 1 -0.395 0.151z"/>
+ <path fill="#FFF" d="M140.974 61.234h-104.772a1.176 1.176 0 0 1 0 -2.353h104.772a1.176 1.176 0 0 1 0 2.353zm-120.522 -46.508h14.187s-4.44 -9.92 4.984 -11.204c8.405 -1.144 11.727 7.493 11.727 7.493s0.907 -4.089 5.995 -4.03c4.755 0.055 8.38 7.915 8.567 8.867h12.354"/>
+ <path fill="#EAEAEE" d="M35.055 13.136h-14.167a0.59 0.59 0 1 1 0 -1.18h14.167a0.59 0.59 0 1 1 0 1.18zm43.647 -0.185h-0.545a0.59 0.59 0 0 1 0 -1.18h0.545a0.59 0.59 0 0 1 0 1.18zm-5.267 0h-3.542a0.59 0.59 0 0 1 0 -1.18h3.542a0.59 0.59 0 0 1 0 1.18zm-21.648 -3.526a0.614 0.614 0 0 1 -0.554 -0.384l-0.087 -0.208a0.59 0.59 0 0 1 0.23 -0.739 5.483 5.483 0 0 1 5.186 -4 7.111 7.111 0 0 1 1.33 0.131 10.622 10.622 0 0 1 5.266 3.045 0.59 0.59 0 0 1 -0.822 0.848 9.484 9.484 0 0 0 -4.665 -2.733 5.934 5.934 0 0 0 -1.109 -0.11c-3.403 0 -4.166 3.526 -4.196 3.677a0.592 0.592 0 0 1 -0.58 0.473zm-16.422 -5.27a0.59 0.59 0 0 1 -0.45 -0.973 6.799 6.799 0 0 1 3.235 -2.027 0.59 0.59 0 0 1 0.352 1.127 5.632 5.632 0 0 0 -2.688 1.665 0.589 0.589 0 0 1 -0.45 0.208zm8.783 -1.988a0.601 0.601 0 0 1 -0.155 -0.02 9.053 9.053 0 0 0 -1.083 -0.224 0.59 0.59 0 0 1 -0.5 -0.669 0.6 0.6 0 0 1 0.668 -0.5 10.36 10.36 0 0 1 1.224 0.253 0.59 0.59 0 0 1 -0.154 1.16z"/>
+ <path fill="#FFF" d="M79.18 17.096h-58.468a1.18 1.18 0 1 1 0 -2.361h58.468a1.18 1.18 0 1 1 0 2.36z"/>
+ <path fill="url(#a)" d="M125.948 119.063h-93.75a2.821 2.821 0 0 1 -2.442 -4.232l46.874 -81.19a2.82 2.82 0 0 1 4.887 0.001l46.874 81.19 -0.001 -0.001a2.821 2.821 0 0 1 -2.442 4.232zm-46.875 -84.333a0.32 0.32 0 0 0 -0.277 0.16l-46.875 81.192a0.32 0.32 0 0 0 0.276 0.481h93.751a0.32 0.32 0 0 0 0.278 -0.48l-0.001 -0.001 -46.874 -81.19a0.32 0.32 0 0 0 -0.278 -0.162z"/>
+ <path fill="#F9F9FA" d="M79.073 34.73a0.32 0.32 0 0 0 -0.277 0.16l-46.875 81.192a0.32 0.32 0 0 0 0.276 0.481h93.751a0.32 0.32 0 0 0 0.278 -0.48l-0.001 -0.001 -46.874 -81.19a0.32 0.32 0 0 0 -0.278 -0.162z"/>
+ <ellipse cx="79.424" cy="140.995" fill="#EAEAEE" rx="49.833" ry="5.802"/>
+ <path fill="url(#b)" d="M79.073 42.313a0.275 0.275 0 0 0 -0.238 0.138l-40.24 69.699a0.275 0.275 0 0 0 0.237 0.413h80.481a0.275 0.275 0 0 0 0.238 -0.412v-0.001l-40.24 -69.699a0.274 0.274 0 0 0 -0.238 -0.138z"/>
+ <path fill="url(#c)" d="M83.67 87.928h-9.19l-1.535 -25.095h12.255l-1.53 25.095zm1.473 11.018c0 3.35 -2.717 6.067 -6.068 6.067 -3.35 0 -6.067 -2.716 -6.067 -6.067s2.716 -6.068 6.067 -6.068a6.1 6.1 0 0 1 6.068 6.068z"/>
+ </g>
+</svg>
diff --git a/browser/components/payments/res/debugging.css b/browser/components/payments/res/debugging.css
new file mode 100644
index 0000000000..6ce0dcbcf9
--- /dev/null
+++ b/browser/components/payments/res/debugging.css
@@ -0,0 +1,35 @@
+/* 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/. */
+
+html {
+ color: -moz-DialogText;
+ font: message-box;
+ /* Make sure the background ends to the bottom if there is unused space */
+ height: 100%;
+}
+
+h1 {
+ font-size: 1em;
+}
+
+fieldset > label {
+ white-space: nowrap;
+}
+
+.group {
+ margin: 0.5em 0;
+}
+
+label.block {
+ display: block;
+ margin: 0.3em 0;
+}
+
+button.wide {
+ width: 100%;
+}
+
+#complete-status {
+ column-count: 2;
+}
diff --git a/browser/components/payments/res/debugging.html b/browser/components/payments/res/debugging.html
new file mode 100644
index 0000000000..9b5b80c9e6
--- /dev/null
+++ b/browser/components/payments/res/debugging.html
@@ -0,0 +1,75 @@
+<!DOCTYPE html>
+<!-- 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/. -->
+<html>
+ <head>
+ <meta charset="utf-8">
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
+ <link rel="stylesheet" href="debugging.css"/>
+ <script src="debugging.js"></script>
+ </head>
+ <body>
+ <div>
+ <section class="group">
+ <button id="refresh">Refresh</button>
+ <button id="rerender">Re-render</button>
+ <button id="logState">Log state</button>
+ <button id="debugFrame" hidden>Debug frame</button>
+ <button id="toggleDirectionality">Toggle :dir</button>
+ <button id="toggleBranding">Toggle branding</button>
+ </section>
+ <section class="group">
+ <h1>Requests</h1>
+ <button id="setRequest1">Request 1</button>
+ <button id="setRequest2">Request 2</button>
+ <fieldset id="paymentOptions">
+ <legend>Payment Options</legend>
+ <label><input type="checkbox" autocomplete="off" name="requestPayerName" id="setRequestPayerName">requestPayerName</label>
+ <label><input type="checkbox" autocomplete="off" name="requestPayerEmail" id="setRequestPayerEmail">requestPayerEmail</label>
+ <label><input type="checkbox" autocomplete="off" name="requestPayerPhone" id="setRequestPayerPhone">requestPayerPhone</label>
+ <label><input type="checkbox" autocomplete="off" name="requestShipping" id="setRequestShipping">requestShipping</label>
+ </fieldset>
+ </section>
+
+ <section class="group">
+ <h1>Addresses</h1>
+ <button id="setAddresses1">Set Addreses 1</button>
+ <button id="setDupesAddresses">Set Duped Addresses</button>
+ <button id="delete1Address">Delete 1 Address</button>
+ </section>
+
+ <section class="group">
+ <h1>Payment Methods</h1>
+ <button id="setBasicCards1">Set Basic Cards 1</button>
+ <button id="delete1Card">Delete 1 Card</button>
+ </section>
+
+ <section class="group">
+ <h1>States</h1>
+ <fieldset id="complete-status">
+ <legend>Complete Status</legend>
+ <label class="block"><input type="radio" name="setCompleteStatus" value="">(default)</label>
+ <label class="block"><input type="radio" name="setCompleteStatus" value="processing">Processing</label>
+ <label class="block"><input type="radio" name="setCompleteStatus" value="fail">Fail</label>
+ <label class="block"><input type="radio" name="setCompleteStatus" value="unknown">Unknown</label>
+ <label class="block"><input type="radio" name="setCompleteStatus" value="timeout">Timeout</label>
+ </fieldset>
+ <label class="block"><input type="checkbox" id="setChangesPrevented">Prevent changes</label>
+
+
+ <section class="group">
+ <fieldset>
+ <legend>User Data Errors</legend>
+ <button id="saveVisibleForm" title="Bypasses field validation">Save Visible Form</button>
+ <button id="setBasicCardErrors">Basic Card Errors</button>
+ <button id="setPayerErrors">Payer Errors</button>
+ <button id="setShippingError">Shipping Error</button>
+ <button id="setShippingAddressErrors">Shipping Address Errors</button>
+
+ </fieldset>
+ </section>
+ </section>
+ </div>
+ </body>
+</html>
diff --git a/browser/components/payments/res/debugging.js b/browser/components/payments/res/debugging.js
new file mode 100644
index 0000000000..fffc85dd11
--- /dev/null
+++ b/browser/components/payments/res/debugging.js
@@ -0,0 +1,664 @@
+/* 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/. */
+
+const paymentDialog = window.parent.document.querySelector("payment-dialog");
+// The requestStore should be manipulated for most changes but autofill storage changes
+// happen through setStateFromParent which includes some consistency checks.
+const requestStore = paymentDialog.requestStore;
+
+// keep the payment options checkboxes in sync w. actual state
+const paymentOptionsUpdater = {
+ stateChangeCallback(state) {
+ this.render(state);
+ },
+ render(state) {
+ let { completeStatus, paymentOptions } = state.request;
+
+ document.getElementById("setChangesPrevented").checked =
+ state.changesPrevented;
+
+ let paymentOptionInputs = document.querySelectorAll(
+ "#paymentOptions input[type='checkbox']"
+ );
+ for (let input of paymentOptionInputs) {
+ if (paymentOptions.hasOwnProperty(input.name)) {
+ input.checked = paymentOptions[input.name];
+ }
+ }
+
+ let completeStatusInputs = document.querySelectorAll(
+ "input[type='radio'][name='setCompleteStatus']"
+ );
+ for (let input of completeStatusInputs) {
+ input.checked = input.value == completeStatus;
+ }
+ },
+};
+
+let REQUEST_1 = {
+ tabId: 9,
+ topLevelPrincipal: { URI: { displayHost: "debugging.example.com" } },
+ requestId: "3797081f-a96b-c34b-a58b-1083c6e66e25",
+ completeStatus: "",
+ paymentMethods: [],
+ paymentDetails: {
+ id: "",
+ totalItem: {
+ label: "Demo total",
+ amount: { currency: "EUR", value: "1.00" },
+ pending: false,
+ },
+ displayItems: [
+ {
+ label: "Square",
+ amount: {
+ currency: "USD",
+ value: "5",
+ },
+ },
+ ],
+ payerErrors: {},
+ paymentMethodErrors: {},
+ shippingAddressErrors: {},
+ shippingOptions: [
+ {
+ id: "std",
+ label: "Standard (3-5 business days)",
+ amount: {
+ currency: "USD",
+ value: 10,
+ },
+ selected: false,
+ },
+ {
+ id: "super-slow",
+ // Long to test truncation
+ label: "Ssssssssuuuuuuuuupppppeeeeeeerrrrr sssssllllllloooooowwwwww",
+ amount: {
+ currency: "USD",
+ value: 1.5,
+ },
+ selected: true,
+ },
+ ],
+ modifiers: null,
+ error: "",
+ },
+ paymentOptions: {
+ requestPayerName: true,
+ requestPayerEmail: false,
+ requestPayerPhone: false,
+ requestShipping: true,
+ shippingType: "shipping",
+ },
+ shippingOption: "std",
+};
+
+let REQUEST_2 = {
+ tabId: 9,
+ topLevelPrincipal: { URI: { displayHost: "example.com" } },
+ requestId: "3797081f-a96b-c34b-a58b-1083c6e66e25",
+ completeStatus: "",
+ paymentMethods: [
+ {
+ supportedMethods: "basic-card",
+ data: {
+ supportedNetworks: ["amex", "discover", "mastercard", "visa"],
+ },
+ },
+ ],
+ paymentDetails: {
+ id: "",
+ totalItem: {
+ label: "",
+ amount: { currency: "CAD", value: "25.75" },
+ pending: false,
+ },
+ displayItems: [
+ {
+ label: "Triangle",
+ amount: {
+ currency: "CAD",
+ value: "3",
+ },
+ },
+ {
+ label: "Circle",
+ amount: {
+ currency: "EUR",
+ value: "10.50",
+ },
+ },
+ {
+ label: "Tax",
+ type: "tax",
+ amount: {
+ currency: "USD",
+ value: "1.50",
+ },
+ },
+ ],
+ payerErrors: {},
+ paymentMethoErrors: {},
+ shippingAddressErrors: {},
+ shippingOptions: [
+ {
+ id: "123",
+ label: "Fast (default)",
+ amount: {
+ currency: "USD",
+ value: 10,
+ },
+ selected: true,
+ },
+ {
+ id: "947",
+ label: "Slow",
+ amount: {
+ currency: "USD",
+ value: 1,
+ },
+ selected: false,
+ },
+ ],
+ modifiers: [
+ {
+ supportedMethods: "basic-card",
+ total: {
+ label: "Total",
+ amount: {
+ currency: "CAD",
+ value: "28.75",
+ },
+ pending: false,
+ },
+ additionalDisplayItems: [
+ {
+ label: "Credit card fee",
+ amount: {
+ currency: "CAD",
+ value: "1.50",
+ },
+ },
+ ],
+ data: {},
+ },
+ ],
+ error: "",
+ },
+ paymentOptions: {
+ requestPayerName: false,
+ requestPayerEmail: false,
+ requestPayerPhone: false,
+ requestShipping: true,
+ shippingType: "shipping",
+ },
+ shippingOption: "123",
+};
+
+let ADDRESSES_1 = {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ country: "US",
+ email: "foo@bar.com",
+ "family-name": "Smith",
+ "given-name": "John",
+ guid: "48bnds6854t",
+ name: "John Smith",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ tel: "+1 519 555-5555",
+ timeLastUsed: 50000,
+ },
+ "68gjdh354j": {
+ "additional-name": "Z.",
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ country: "US",
+ "family-name": "Doe",
+ "given-name": "Jane",
+ guid: "68gjdh354j",
+ name: "Jane Z. Doe",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ tel: "+1 650 555-5555",
+ timeLastUsed: 30000,
+ },
+ abcde12345: {
+ "address-level2": "Mountain View",
+ country: "US",
+ "family-name": "Fields",
+ "given-name": "Mrs.",
+ guid: "abcde12345",
+ name: "Mrs. Fields",
+ timeLastUsed: 70000,
+ },
+ german1: {
+ "additional-name": "Y.",
+ "address-level1": "",
+ "address-level2": "Berlin",
+ country: "DE",
+ email: "de@example.com",
+ "family-name": "Mouse",
+ "given-name": "Anon",
+ guid: "german1",
+ name: "Anon Y. Mouse",
+ organization: "Mozilla",
+ "postal-code": "10997",
+ "street-address": "Schlesische Str. 27",
+ tel: "+49 30 983333002",
+ timeLastUsed: 10000,
+ },
+ "missing-country": {
+ "address-level1": "ON",
+ "address-level2": "Toronto",
+ "family-name": "Bogard",
+ "given-name": "Kristin",
+ guid: "missing-country",
+ name: "Kristin Bogard",
+ "postal-code": "H0H 0H0",
+ "street-address": "123 Yonge Street\nSuite 2300",
+ tel: "+1 416 555-5555",
+ timeLastUsed: 90000,
+ },
+ TimBR: {
+ "given-name": "Timothy",
+ "additional-name": "João",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "Rua Adalberto Pajuaba, 404",
+ "address-level3": "Campos Elísios",
+ "address-level2": "Ribeirão Preto",
+ "address-level1": "SP",
+ "postal-code": "14055-220",
+ country: "BR",
+ tel: "+0318522222222",
+ email: "timbr@example.org",
+ timeLastUsed: 110000,
+ },
+};
+
+let DUPED_ADDRESSES = {
+ a9e830667189: {
+ "street-address": "Unit 1\n1505 Northeast Kentucky Industrial Parkway \n",
+ "address-level2": "Greenup",
+ "address-level1": "KY",
+ "postal-code": "41144",
+ country: "US",
+ email: "bob@example.com",
+ "family-name": "Smith",
+ "given-name": "Bob",
+ guid: "a9e830667189",
+ tel: "+19871234567",
+ name: "Bob Smith",
+ timeLastUsed: 10001,
+ },
+ "72a15aed206d": {
+ "street-address": "1 New St",
+ "address-level2": "York",
+ "address-level1": "SC",
+ "postal-code": "29745",
+ country: "US",
+ "given-name": "Mary Sue",
+ guid: "72a15aed206d",
+ tel: "+19871234567",
+ name: "Mary Sue",
+ "address-line1": "1 New St",
+ timeLastUsed: 10009,
+ },
+ "2b4dce0fbc1f": {
+ "street-address": "123 Park St",
+ "address-level2": "Springfield",
+ "address-level1": "OR",
+ "postal-code": "97403",
+ country: "US",
+ email: "rita@foo.com",
+ "family-name": "Foo",
+ "given-name": "Rita",
+ guid: "2b4dce0fbc1f",
+ name: "Rita Foo",
+ "address-line1": "123 Park St",
+ timeLastUsed: 10005,
+ },
+ "46b2635a5b26": {
+ "street-address": "432 Another St",
+ "address-level2": "Springfield",
+ "address-level1": "OR",
+ "postal-code": "97402",
+ country: "US",
+ email: "rita@foo.com",
+ "family-name": "Foo",
+ "given-name": "Rita",
+ guid: "46b2635a5b26",
+ name: "Rita Foo",
+ "address-line1": "432 Another St",
+ timeLastUsed: 10003,
+ },
+};
+
+let BASIC_CARDS_1 = {
+ "53f9d009aed2": {
+ billingAddressGUID: "68gjdh354j",
+ methodName: "basic-card",
+ "cc-number": "************5461",
+ guid: "53f9d009aed2",
+ version: 3,
+ timeCreated: 1505240896213,
+ timeLastModified: 1515609524588,
+ timeLastUsed: 10000,
+ timesUsed: 0,
+ "cc-name": "John Smith",
+ "cc-exp-month": 6,
+ "cc-exp-year": 2024,
+ "cc-type": "visa",
+ "cc-given-name": "John",
+ "cc-additional-name": "",
+ "cc-family-name": "Smith",
+ "cc-exp": "2024-06",
+ },
+ "9h5d4h6f4d1s": {
+ methodName: "basic-card",
+ "cc-number": "************0954",
+ guid: "9h5d4h6f4d1s",
+ version: 3,
+ timeCreated: 1517890536491,
+ timeLastModified: 1517890564518,
+ timeLastUsed: 50000,
+ timesUsed: 0,
+ "cc-name": "Jane Doe",
+ "cc-exp-month": 5,
+ "cc-exp-year": 2023,
+ "cc-type": "mastercard",
+ "cc-given-name": "Jane",
+ "cc-additional-name": "",
+ "cc-family-name": "Doe",
+ "cc-exp": "2023-05",
+ },
+ "123456789abc": {
+ methodName: "basic-card",
+ "cc-number": "************1234",
+ guid: "123456789abc",
+ version: 3,
+ timeCreated: 1517890536491,
+ timeLastModified: 1517890564518,
+ timeLastUsed: 90000,
+ timesUsed: 0,
+ "cc-name": "Jane Fields",
+ "cc-given-name": "Jane",
+ "cc-additional-name": "",
+ "cc-family-name": "Fields",
+ "cc-type": "discover",
+ },
+ "amex-card": {
+ methodName: "basic-card",
+ billingAddressGUID: "68gjdh354j",
+ "cc-number": "************1941",
+ guid: "amex-card",
+ version: 1,
+ timeCreated: 1517890536491,
+ timeLastModified: 1517890564518,
+ timeLastUsed: 70000,
+ timesUsed: 0,
+ "cc-name": "Capt America",
+ "cc-given-name": "Capt",
+ "cc-additional-name": "",
+ "cc-family-name": "America",
+ "cc-type": "amex",
+ "cc-exp-month": 6,
+ "cc-exp-year": 2023,
+ "cc-exp": "2023-06",
+ },
+ "missing-cc-name": {
+ methodName: "basic-card",
+ "cc-number": "************8563",
+ guid: "missing-cc-name",
+ version: 3,
+ timeCreated: 1517890536491,
+ timeLastModified: 1517890564518,
+ timeLastUsed: 30000,
+ timesUsed: 0,
+ "cc-exp-month": 8,
+ "cc-exp-year": 2024,
+ "cc-exp": "2024-08",
+ },
+};
+
+let buttonActions = {
+ debugFrame() {
+ let event = new CustomEvent("paymentContentToChrome", {
+ bubbles: true,
+ detail: {
+ messageType: "debugFrame",
+ },
+ });
+ document.dispatchEvent(event);
+ },
+
+ delete1Address() {
+ let savedAddresses = Object.assign(
+ {},
+ requestStore.getState().savedAddresses
+ );
+ delete savedAddresses[Object.keys(savedAddresses)[0]];
+ // Use setStateFromParent since it ensures there is no dangling
+ // `selectedShippingAddress` foreign key (FK) reference.
+ paymentDialog.setStateFromParent({
+ savedAddresses,
+ });
+ },
+
+ delete1Card() {
+ let savedBasicCards = Object.assign(
+ {},
+ requestStore.getState().savedBasicCards
+ );
+ delete savedBasicCards[Object.keys(savedBasicCards)[0]];
+ // Use setStateFromParent since it ensures there is no dangling
+ // `selectedPaymentCard` foreign key (FK) reference.
+ paymentDialog.setStateFromParent({
+ savedBasicCards,
+ });
+ },
+
+ logState() {
+ let state = requestStore.getState();
+ // eslint-disable-next-line no-console
+ console.log(state);
+ dump(`${JSON.stringify(state, null, 2)}\n`);
+ },
+
+ refresh() {
+ window.parent.location.reload(true);
+ },
+
+ rerender() {
+ requestStore.setState({});
+ },
+
+ saveVisibleForm() {
+ // Bypasses field validation which is useful to test error handling.
+ paymentDialog
+ .querySelector("#main-container > .page:not([hidden])")
+ .saveRecord();
+ },
+
+ setAddresses1() {
+ paymentDialog.setStateFromParent({ savedAddresses: ADDRESSES_1 });
+ },
+
+ setDupesAddresses() {
+ paymentDialog.setStateFromParent({ savedAddresses: DUPED_ADDRESSES });
+ },
+
+ setBasicCards1() {
+ paymentDialog.setStateFromParent({ savedBasicCards: BASIC_CARDS_1 });
+ },
+
+ setBasicCardErrors() {
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign(
+ {},
+ requestStore.getState().request.paymentDetails
+ );
+ request.paymentDetails.paymentMethodErrors = {
+ cardNumber: "",
+ cardholderName: "",
+ cardSecurityCode: "",
+ expiryMonth: "",
+ expiryYear: "",
+ billingAddress: {
+ addressLine:
+ "Can only buy from ROADS, not DRIVES, BOULEVARDS, or STREETS",
+ city: "Can only buy from CITIES, not TOWNSHIPS or VILLAGES",
+ country: "Can only buy from US, not CA",
+ dependentLocality: "Can only be SUBURBS, not NEIGHBORHOODS",
+ organization: "Can only buy from CORPORATIONS, not CONSORTIUMS",
+ phone: "Only allowed to buy from area codes that start with 9",
+ postalCode: "Only allowed to buy from postalCodes that start with 0",
+ recipient: "Can only buy from names that start with J",
+ region: "Can only buy from regions that start with M",
+ regionCode: "Regions must be 1 to 3 characters in length",
+ },
+ };
+ requestStore.setState({
+ request,
+ });
+ },
+
+ setChangesPrevented(evt) {
+ requestStore.setState({
+ changesPrevented: evt.target.checked,
+ });
+ },
+
+ setCompleteStatus() {
+ let input = document.querySelector("[name='setCompleteStatus']:checked");
+ let completeStatus = input.value;
+ let request = requestStore.getState().request;
+ paymentDialog.setStateFromParent({
+ request: Object.assign({}, request, { completeStatus }),
+ });
+ },
+
+ setPayerErrors() {
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign(
+ {},
+ requestStore.getState().request.paymentDetails
+ );
+ request.paymentDetails.payerErrors = {
+ email: "Only @mozilla.com emails are supported",
+ name: "Payer name must start with M",
+ phone: "Payer area codes must start with 1",
+ };
+ requestStore.setState({
+ request,
+ });
+ },
+
+ setPaymentOptions() {
+ let options = {};
+ let checkboxes = document.querySelectorAll(
+ "#paymentOptions input[type='checkbox']"
+ );
+ for (let input of checkboxes) {
+ options[input.name] = input.checked;
+ }
+ let req = Object.assign({}, requestStore.getState().request, {
+ paymentOptions: options,
+ });
+ requestStore.setState({ request: req });
+ },
+
+ setRequest1() {
+ paymentDialog.setStateFromParent({ request: REQUEST_1 });
+ },
+
+ setRequest2() {
+ paymentDialog.setStateFromParent({ request: REQUEST_2 });
+ },
+
+ setRequestPayerName() {
+ buttonActions.setPaymentOptions();
+ },
+ setRequestPayerEmail() {
+ buttonActions.setPaymentOptions();
+ },
+ setRequestPayerPhone() {
+ buttonActions.setPaymentOptions();
+ },
+ setRequestShipping() {
+ buttonActions.setPaymentOptions();
+ },
+
+ setShippingError() {
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign(
+ {},
+ requestStore.getState().request.paymentDetails
+ );
+ request.paymentDetails.error = "Shipping Error!";
+ request.paymentDetails.shippingOptions = [];
+ requestStore.setState({
+ request,
+ });
+ },
+
+ setShippingAddressErrors() {
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign(
+ {},
+ requestStore.getState().request.paymentDetails
+ );
+ request.paymentDetails.shippingAddressErrors = {
+ addressLine: "Can only ship to ROADS, not DRIVES, BOULEVARDS, or STREETS",
+ city: "Can only ship to CITIES, not TOWNSHIPS or VILLAGES",
+ country: "Can only ship to USA, not CA",
+ dependentLocality: "Can only be SUBURBS, not NEIGHBORHOODS",
+ organization: "Can only ship to CORPORATIONS, not CONSORTIUMS",
+ phone: "Only allowed to ship to area codes that start with 9",
+ postalCode: "Only allowed to ship to postalCodes that start with 0",
+ recipient: "Can only ship to names that start with J",
+ region: "Can only ship to regions that start with M",
+ regionCode: "Regions must be 1 to 3 characters in length",
+ };
+ requestStore.setState({
+ request,
+ });
+ },
+
+ toggleDirectionality() {
+ let body = paymentDialog.ownerDocument.body;
+ body.dir = body.dir == "rtl" ? "ltr" : "rtl";
+ },
+
+ toggleBranding() {
+ for (let container of paymentDialog.querySelectorAll("accepted-cards")) {
+ container.classList.toggle("branded");
+ }
+ },
+};
+
+window.addEventListener("click", function onButtonClick(evt) {
+ let id = evt.target.id || evt.target.name;
+ if (!id || typeof buttonActions[id] != "function") {
+ return;
+ }
+
+ buttonActions[id](evt);
+});
+
+window.addEventListener("DOMContentLoaded", function onDCL() {
+ if (window.location.protocol == "resource:") {
+ // Only show the debug frame button if we're running from a resource URI
+ // so it doesn't show during development over file: or http: since it won't work.
+ // Note that the button still won't work if resource://payments/paymentRequest.xhtml
+ // is manually loaded in a tab but will be shown.
+ document.getElementById("debugFrame").hidden = false;
+ }
+
+ requestStore.subscribe(paymentOptionsUpdater);
+ paymentOptionsUpdater.render(requestStore.getState());
+});
diff --git a/browser/components/payments/res/mixins/HandleEventMixin.js b/browser/components/payments/res/mixins/HandleEventMixin.js
new file mode 100644
index 0000000000..8d09ac2207
--- /dev/null
+++ b/browser/components/payments/res/mixins/HandleEventMixin.js
@@ -0,0 +1,28 @@
+/* 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/. */
+
+/**
+ * A mixin to forward events to on* methods if defined.
+ *
+ * @param {string} superclass The class to extend.
+ * @returns {class}
+ */
+export default function HandleEventMixin(superclass) {
+ return class HandleEvent extends superclass {
+ handleEvent(evt) {
+ function capitalize(str) {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+ }
+ if (super.handleEvent) {
+ super.handleEvent(evt);
+ }
+ // Check whether event name is a defined function in object.
+ let fn = "on" + capitalize(evt.type);
+ if (this[fn] && typeof this[fn] === "function") {
+ return this[fn](evt);
+ }
+ return null;
+ }
+ };
+}
diff --git a/browser/components/payments/res/mixins/ObservedPropertiesMixin.js b/browser/components/payments/res/mixins/ObservedPropertiesMixin.js
new file mode 100644
index 0000000000..5fe71af90c
--- /dev/null
+++ b/browser/components/payments/res/mixins/ObservedPropertiesMixin.js
@@ -0,0 +1,71 @@
+/* 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/. */
+
+/**
+ * Define getters and setters for observedAttributes converted to camelCase and
+ * trigger a batched aynchronous call to `render` upon observed
+ * attribute/property changes.
+ */
+
+export default function ObservedPropertiesMixin(superClass) {
+ return class ObservedProperties extends superClass {
+ static kebabToCamelCase(name) {
+ return name.replace(/-([a-z])/g, ($0, $1) => $1.toUpperCase());
+ }
+
+ constructor() {
+ super();
+
+ this._observedPropertiesMixin = {
+ pendingRender: false,
+ };
+
+ // Reflect property changes for `observedAttributes` to attributes.
+ for (let name of this.constructor.observedAttributes || []) {
+ if (name in this) {
+ // Don't overwrite existing properties.
+ continue;
+ }
+ // Convert attribute names from kebab-case to camelCase properties
+ Object.defineProperty(this, ObservedProperties.kebabToCamelCase(name), {
+ configurable: true,
+ get() {
+ return this.getAttribute(name);
+ },
+ set(value) {
+ if (value === null || value === undefined || value === false) {
+ this.removeAttribute(name);
+ } else {
+ this.setAttribute(name, value);
+ }
+ },
+ });
+ }
+ }
+
+ async _invalidateFromObservedPropertiesMixin() {
+ if (this._observedPropertiesMixin.pendingRender) {
+ return;
+ }
+
+ this._observedPropertiesMixin.pendingRender = true;
+ await Promise.resolve();
+ try {
+ this.render();
+ } finally {
+ this._observedPropertiesMixin.pendingRender = false;
+ }
+ }
+
+ attributeChangedCallback(attr, oldValue, newValue) {
+ if (super.attributeChangedCallback) {
+ super.attributeChangedCallback(attr, oldValue, newValue);
+ }
+ if (oldValue === newValue) {
+ return;
+ }
+ this._invalidateFromObservedPropertiesMixin();
+ }
+ };
+}
diff --git a/browser/components/payments/res/mixins/PaymentStateSubscriberMixin.js b/browser/components/payments/res/mixins/PaymentStateSubscriberMixin.js
new file mode 100644
index 0000000000..ede9e1bfc5
--- /dev/null
+++ b/browser/components/payments/res/mixins/PaymentStateSubscriberMixin.js
@@ -0,0 +1,112 @@
+/* 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 PaymentsStore from "../PaymentsStore.js";
+
+/**
+ * A mixin for a custom element to observe store changes to information about a payment request.
+ */
+
+/**
+ * State of the payment request dialog.
+ */
+export let requestStore = new PaymentsStore({
+ changesPrevented: false,
+ orderDetailsShowing: false,
+ "basic-card-page": {
+ guid: null,
+ // preserveFieldValues: true,
+ selectedStateKey: "selectedPaymentCard",
+ },
+ "shipping-address-page": {
+ guid: null,
+ },
+ "payer-address-page": {
+ guid: null,
+ },
+ "billing-address-page": {
+ guid: null,
+ },
+ "payment-summary": {},
+ page: {
+ id: "payment-summary",
+ previousId: null,
+ // onboardingWizard: true,
+ // error: "",
+ },
+ request: {
+ completeStatus: "",
+ tabId: null,
+ topLevelPrincipal: { URI: { displayHost: null } },
+ requestId: null,
+ paymentMethods: [],
+ paymentDetails: {
+ id: null,
+ totalItem: { label: null, amount: { currency: null, value: 0 } },
+ displayItems: [],
+ payerErrors: {},
+ paymentMethodErrors: null,
+ shippingAddressErrors: {},
+ shippingOptions: [],
+ modifiers: null,
+ error: "",
+ },
+ paymentOptions: {
+ requestPayerName: false,
+ requestPayerEmail: false,
+ requestPayerPhone: false,
+ requestShipping: false,
+ shippingType: "shipping",
+ },
+ shippingOption: null,
+ },
+ selectedPayerAddress: null,
+ selectedPaymentCard: null,
+ selectedPaymentCardSecurityCode: null,
+ selectedShippingAddress: null,
+ selectedShippingOption: null,
+ savedAddresses: {},
+ savedBasicCards: {},
+ tempAddresses: {},
+ 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
+ * @returns {class}
+ */
+export default function PaymentStateSubscriberMixin(superClass) {
+ return class PaymentStateSubscriber extends superClass {
+ constructor() {
+ super();
+ this.requestStore = requestStore;
+ }
+
+ connectedCallback() {
+ this.requestStore.subscribe(this);
+ this.render(this.requestStore.getState());
+ if (super.connectedCallback) {
+ super.connectedCallback();
+ }
+ }
+
+ disconnectedCallback() {
+ this.requestStore.unsubscribe(this);
+ if (super.disconnectedCallback) {
+ super.disconnectedCallback();
+ }
+ }
+
+ /**
+ * Called by the store upon state changes.
+ * @param {object} state The current state
+ */
+ stateChangeCallback(state) {
+ this.render(state);
+ }
+ };
+}
diff --git a/browser/components/payments/res/paymentRequest.css b/browser/components/payments/res/paymentRequest.css
new file mode 100644
index 0000000000..ef03c745ae
--- /dev/null
+++ b/browser/components/payments/res/paymentRequest.css
@@ -0,0 +1,265 @@
+/* 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/. */
+
+:root {
+ height: 100%;
+}
+
+body {
+ height: 100%;
+ margin: 0;
+ /* Override font-size from in-content/common.css which is too large */
+ font-size: inherit;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+#debugging-console {
+ /* include the default borders in the max-height */
+ box-sizing: border-box;
+ float: right;
+ height: 100vh;
+ /* Float above the other overlays */
+ position: relative;
+ z-index: 99;
+}
+
+payment-dialog {
+ box-sizing: border-box;
+ display: grid;
+ grid-template: "header" auto
+ "main" 1fr
+ "disabled-overlay" auto;
+ height: 100%;
+}
+
+payment-dialog > header,
+.page > .page-body,
+.page > footer {
+ padding: 0 10%;
+}
+
+payment-dialog > header {
+ border-bottom: 1px solid rgba(0,0,0,0.1);
+ display: flex;
+ /* Wrap so that the error text appears full-width above the rest of the contents */
+ flex-wrap: wrap;
+ /* from visual spec: */
+ padding-bottom: 19px;
+ padding-top: 19px;
+}
+
+payment-dialog > header > .page-error:empty {
+ display: none;
+}
+
+payment-dialog > header > .page-error {
+ background: #D70022;
+ border-radius: 3px;
+ color: white;
+ padding: 6px;
+ width: 100%;
+}
+
+#main-container {
+ display: flex;
+ grid-area: main;
+ position: relative;
+ max-height: 100%;
+}
+
+.page {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ position: relative;
+ width: 100%;
+}
+
+.page > .page-body {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ /* The area above the footer should scroll, if necessary. */
+ overflow: auto;
+ padding-top: 18px;
+}
+
+.page > .page-body > h2:empty {
+ display: none;
+}
+
+.page-error {
+ color: #D70022;
+}
+
+.manage-text {
+ margin: 0;
+ padding: 18px 0;
+}
+
+.page > footer {
+ align-items: center;
+ justify-content: end;
+ background-color: #eaeaee;
+ display: flex;
+ /* from visual spec: */
+ padding-top: 20px;
+ padding-bottom: 18px;
+}
+
+#order-details-overlay {
+ background-color: var(--in-content-page-background);
+ overflow: auto;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 1;
+}
+
+#total {
+ flex: 1 1 auto;
+ margin: 5px;
+}
+
+#total > currency-amount {
+ color: var(--in-content-link-color);
+ font-size: 1.5em;
+}
+
+#total > currency-amount > .currency-code {
+ color: GrayText;
+ font-size: 1rem;
+}
+
+#total > div {
+ color: GrayText;
+}
+
+#view-all {
+ flex: 0 1 auto;
+}
+
+payment-dialog[complete-status="processing"] #pay {
+ /* Force opacity to 1 even when disabled in the processing state. */
+ opacity: 1;
+}
+
+payment-dialog #pay::before {
+ -moz-context-properties: fill;
+ content: url(chrome://browser/skin/connection-secure.svg);
+ fill: currentColor;
+ height: 16px;
+ margin-inline-end: 0.5em;
+ vertical-align: text-bottom;
+ width: 16px;
+}
+
+payment-dialog[changes-prevented][complete-status="fail"] #pay,
+payment-dialog[changes-prevented][complete-status="unknown"] #pay,
+payment-dialog[changes-prevented][complete-status="processing"] #pay,
+payment-dialog[changes-prevented][complete-status="success"] #pay {
+ /* Show the pay button above #disabled-overlay */
+ position: relative;
+ z-index: 51;
+}
+
+#disabled-overlay {
+ background: white;
+ grid-area: disabled-overlay;
+ opacity: 0.6;
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ /* z-index must be greater than some positioned fields and #pay with z-index
+ but less than 99, the z-index of the debugging console. */
+ z-index: 50;
+}
+
+.persist-checkbox {
+ padding: 5px 0;
+}
+
+.persist-checkbox > label {
+ display: flex;
+ align-items: center;
+}
+
+.info-tooltip {
+ display: inline-block;
+ background-image: url(chrome://global/skin/icons/help.svg);
+ width: 16px;
+ height: 16px;
+ padding: 2px 4px;
+ background-repeat: no-repeat;
+ background-position: center;
+ position: relative;
+}
+
+.info-tooltip:focus::after,
+.info-tooltip:hover::after {
+ content: attr(aria-label);
+ display: block;
+ position: absolute;
+ padding: 3px 5px;
+ background-color: #fff;
+ border: 1px solid #bebebf;
+ box-shadow: 1px 1px 3px #bebebf;
+ font-size: smaller;
+ line-height: normal;
+ width: 188px;
+ /* Center the tooltip over the (i) icon (188px / 2 - 5px (padding) - 1px (border)). */
+ left: -86px;
+ bottom: 20px;
+}
+
+.info-tooltip:dir(rtl):focus::after,
+.info-tooltip:dir(rtl):hover::after {
+ left: auto;
+ right: -86px;
+}
+
+.csc.info-tooltip:focus::after,
+.csc.info-tooltip:hover::after {
+ /* Right-align the tooltip over the (i) icon (-188px - 60px (padding) - 2px (border) + 4px ((i) start padding) + 16px ((i) icon width)). */
+ left: -226px;
+ background-position: top 5px left 5px;
+ background-image: url(./containers/cvv-hint-image-back.svg);
+ background-repeat: no-repeat;
+ padding-inline-start: 55px;
+}
+
+.csc.info-tooltip[cc-type="amex"]::after {
+ background-image: url(./containers/cvv-hint-image-front.svg);
+}
+
+.csc.info-tooltip:dir(rtl):focus::after,
+.csc.info-tooltip:dir(rtl):hover::after {
+ left: auto;
+ /* Left-align the tooltip over the (i) icon (-188px - 60px (padding) - 2px (border) + 4px ((i) start padding) + 16px ((i) icon width)). */
+ right: -226px;
+ background-position: top 5px right 5px;
+}
+
+.branding {
+ background-image: url(chrome://branding/content/icon32.png);
+ background-size: 16px;
+ background-repeat: no-repeat;
+ background-position: left center;
+ padding-inline-start: 20px;
+ line-height: 20px;
+ margin-inline-end: auto;
+}
+
+.branding:dir(rtl) {
+ background-position: right center;
+}
diff --git a/browser/components/payments/res/paymentRequest.js b/browser/components/payments/res/paymentRequest.js
new file mode 100644
index 0000000000..1a08184615
--- /dev/null
+++ b/browser/components/payments/res/paymentRequest.js
@@ -0,0 +1,356 @@
+/* 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/. */
+
+/**
+ * Loaded in the unprivileged frame of each payment dialog.
+ *
+ * Communicates with privileged code via DOM Events.
+ */
+
+/* import-globals-from unprivileged-fallbacks.js */
+
+var paymentRequest = {
+ _nextMessageID: 1,
+ domReadyPromise: null,
+
+ init() {
+ // listen to content
+ window.addEventListener("paymentChromeToContent", this);
+
+ window.addEventListener("keydown", this);
+
+ this.domReadyPromise = new Promise(function dcl(resolve) {
+ window.addEventListener("DOMContentLoaded", resolve, { once: true });
+ }).then(this.handleEvent.bind(this));
+
+ // This scope is now ready to listen to the initialization data
+ this.sendMessageToChrome("initializeRequest");
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "DOMContentLoaded": {
+ this.onPaymentRequestLoad();
+ break;
+ }
+ case "keydown": {
+ if (event.code != "KeyD" || !event.altKey || !event.ctrlKey) {
+ break;
+ }
+ this.toggleDebuggingConsole();
+ break;
+ }
+ case "unload": {
+ this.onPaymentRequestUnload();
+ break;
+ }
+ case "paymentChromeToContent": {
+ this.onChromeToContent(event);
+ break;
+ }
+ default: {
+ throw new Error("Unexpected event type");
+ }
+ }
+ },
+
+ /**
+ * @param {string} messageType
+ * @param {[object]} detail
+ * @returns {number} message ID to be able to identify a reply (where applicable).
+ */
+ sendMessageToChrome(messageType, detail = {}) {
+ let messageID = this._nextMessageID++;
+ log.debug("sendMessageToChrome:", messageType, messageID, detail);
+ let event = new CustomEvent("paymentContentToChrome", {
+ bubbles: true,
+ detail: Object.assign(
+ {
+ messageType,
+ messageID,
+ },
+ detail
+ ),
+ });
+ document.dispatchEvent(event);
+ return messageID;
+ },
+
+ toggleDebuggingConsole() {
+ let debuggingConsole = document.getElementById("debugging-console");
+ if (debuggingConsole.hidden && !debuggingConsole.src) {
+ debuggingConsole.src = "debugging.html";
+ }
+ debuggingConsole.hidden = !debuggingConsole.hidden;
+ },
+
+ onChromeToContent({ detail }) {
+ let { messageType } = detail;
+ log.debug("onChromeToContent:", messageType);
+
+ switch (messageType) {
+ case "responseSent": {
+ let { request } = document
+ .querySelector("payment-dialog")
+ .requestStore.getState();
+ document.querySelector("payment-dialog").requestStore.setState({
+ changesPrevented: true,
+ request: Object.assign({}, request, { completeStatus: "processing" }),
+ });
+ break;
+ }
+ case "showPaymentRequest": {
+ this.onShowPaymentRequest(detail);
+ break;
+ }
+ case "updateState": {
+ document.querySelector("payment-dialog").setStateFromParent(detail);
+ break;
+ }
+ }
+ },
+
+ onPaymentRequestLoad() {
+ log.debug("onPaymentRequestLoad");
+ window.addEventListener("unload", this, { once: true });
+
+ // Automatically show the debugging console if loaded with a truthy `debug` query parameter.
+ if (new URLSearchParams(location.search).get("debug")) {
+ this.toggleDebuggingConsole();
+ }
+ },
+
+ 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);
+
+ let paymentDialog = document.querySelector("payment-dialog");
+ let state = {
+ request: detail.request,
+ savedAddresses: detail.savedAddresses,
+ savedBasicCards: detail.savedBasicCards,
+ // Temp records can exist upon a reload during development.
+ tempAddresses: detail.tempAddresses,
+ tempBasicCards: detail.tempBasicCards,
+ isPrivate: detail.isPrivate,
+ page: {
+ id: "payment-summary",
+ },
+ };
+
+ let hasSavedAddresses = !!Object.keys(this.getAddresses(state)).length;
+ let hasSavedCards = !!Object.keys(this.getBasicCards(state)).length;
+ let shippingRequested = state.request.paymentOptions.requestShipping;
+
+ // Onboarding wizard flow.
+ if (!hasSavedAddresses && shippingRequested) {
+ state.page = {
+ id: "shipping-address-page",
+ onboardingWizard: true,
+ };
+
+ state["shipping-address-page"] = {
+ guid: null,
+ };
+ } else if (!hasSavedAddresses && !hasSavedCards) {
+ state.page = {
+ id: "billing-address-page",
+ onboardingWizard: true,
+ };
+
+ state["billing-address-page"] = {
+ guid: null,
+ };
+ } else if (!hasSavedCards) {
+ state.page = {
+ id: "basic-card-page",
+ onboardingWizard: true,
+ };
+ state["basic-card-page"] = {
+ selectedStateKey: "selectedPaymentCard",
+ };
+ }
+
+ await paymentDialog.setStateFromParent(state);
+
+ this.sendMessageToChrome("paymentDialogReady");
+ },
+
+ openPreferences() {
+ this.sendMessageToChrome("openPreferences");
+ },
+
+ cancel() {
+ this.sendMessageToChrome("paymentCancel");
+ },
+
+ pay(data) {
+ this.sendMessageToChrome("pay", data);
+ },
+
+ closeDialog() {
+ this.sendMessageToChrome("closeDialog");
+ },
+
+ changePaymentMethod(data) {
+ this.sendMessageToChrome("changePaymentMethod", data);
+ },
+
+ changeShippingAddress(data) {
+ this.sendMessageToChrome("changeShippingAddress", data);
+ },
+
+ changeShippingOption(data) {
+ this.sendMessageToChrome("changeShippingOption", data);
+ },
+
+ changePayerAddress(data) {
+ this.sendMessageToChrome("changePayerAddress", 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
+ * @returns {Promise} when the update response is received
+ */
+ updateAutofillRecord(collectionName, record, guid) {
+ return new Promise((resolve, reject) => {
+ let messageID = this.sendMessageToChrome("updateAutofillRecord", {
+ collectionName,
+ guid,
+ record,
+ });
+
+ window.addEventListener("paymentChromeToContent", function onMsg({
+ detail,
+ }) {
+ if (
+ detail.messageType != "updateAutofillRecord:Response" ||
+ detail.messageID != messageID
+ ) {
+ return;
+ }
+ log.debug("updateAutofillRecord: response:", detail);
+ window.removeEventListener("paymentChromeToContent", onMsg);
+ document
+ .querySelector("payment-dialog")
+ .setStateFromParent(detail.stateChange);
+ if (detail.error) {
+ reject(detail);
+ } else {
+ resolve(detail);
+ }
+ });
+ });
+ },
+
+ /**
+ * @param {object} state object representing the UI state
+ * @param {string} selectedMethodID (GUID) uniquely identifying the selected payment method
+ * @returns {object?} the applicable modifier for the payment method
+ */
+ getModifierForPaymentMethod(state, selectedMethodID) {
+ let basicCards = this.getBasicCards(state);
+ let selectedMethod = basicCards[selectedMethodID] || null;
+ if (selectedMethod && selectedMethod.methodName !== "basic-card") {
+ throw new Error(
+ `${selectedMethod.methodName} (${selectedMethodID}) ` +
+ `is not a supported payment method`
+ );
+ }
+ let modifiers = state.request.paymentDetails.modifiers;
+ if (!selectedMethod || !modifiers || !modifiers.length) {
+ return null;
+ }
+ let appliedModifier = modifiers.find(modifier => {
+ // take the first matching modifier
+ if (
+ modifier.supportedMethods &&
+ modifier.supportedMethods != selectedMethod.methodName
+ ) {
+ return false;
+ }
+ let supportedNetworks =
+ (modifier.data && modifier.data.supportedNetworks) || [];
+ return (
+ !supportedNetworks.length ||
+ supportedNetworks.includes(selectedMethod["cc-type"])
+ );
+ });
+ return appliedModifier || null;
+ },
+
+ /**
+ * @param {object} state object representing the UI state
+ * @returns {object} in the shape of `nsIPaymentItem` representing the total
+ * that applies to the selected payment method.
+ */
+ getTotalItem(state) {
+ let methodID = state.selectedPaymentCard;
+ if (methodID) {
+ let modifier = paymentRequest.getModifierForPaymentMethod(
+ state,
+ methodID
+ );
+ if (modifier && modifier.hasOwnProperty("total")) {
+ return modifier.total;
+ }
+ }
+ return state.request.paymentDetails.totalItem;
+ },
+
+ onPaymentRequestUnload() {
+ // remove listeners that may be used multiple times here
+ window.removeEventListener("paymentChromeToContent", this);
+ },
+
+ _sortObjectsByTimeLastUsed(objects) {
+ let sortedValues = Object.values(objects).sort((a, b) => {
+ let aLastUsed = a.timeLastUsed || a.timeLastModified;
+ let bLastUsed = b.timeLastUsed || b.timeLastModified;
+ return bLastUsed - aLastUsed;
+ });
+ let sortedObjects = {};
+ for (let obj of sortedValues) {
+ sortedObjects[obj.guid] = obj;
+ }
+ return sortedObjects;
+ },
+
+ getAddresses(state) {
+ let addresses = Object.assign(
+ {},
+ state.savedAddresses,
+ state.tempAddresses
+ );
+ return this._sortObjectsByTimeLastUsed(addresses);
+ },
+
+ getBasicCards(state) {
+ let cards = Object.assign({}, state.savedBasicCards, state.tempBasicCards);
+ return this._sortObjectsByTimeLastUsed(cards);
+ },
+
+ maybeCreateFieldErrorElement(container) {
+ let span = container.querySelector(".error-text");
+ if (!span) {
+ span = document.createElement("span");
+ span.className = "error-text";
+ container.appendChild(span);
+ }
+ return span;
+ },
+};
+
+paymentRequest.init();
+
+export default paymentRequest;
diff --git a/browser/components/payments/res/paymentRequest.xhtml b/browser/components/payments/res/paymentRequest.xhtml
new file mode 100644
index 0000000000..e88b529333
--- /dev/null
+++ b/browser/components/payments/res/paymentRequest.xhtml
@@ -0,0 +1,303 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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/. -->
+<!DOCTYPE html [
+ <!ENTITY % globalDTD SYSTEM "chrome://global/locale/global.dtd">
+ %globalDTD;
+ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" >
+ %brandDTD;
+
+ <!ENTITY viewAllItems "View All Items">
+ <!ENTITY paymentSummaryTitle "Your Payment">
+ <!ENTITY header.payTo "Pay to">
+ <!ENTITY fieldRequiredSymbol "*">
+
+ <!ENTITY shippingAddressLabel "Shipping Address">
+ <!ENTITY deliveryAddressLabel "Delivery Address">
+ <!ENTITY pickupAddressLabel "Pickup Address">
+ <!ENTITY shippingOptionsLabel "Shipping Options">
+ <!ENTITY deliveryOptionsLabel "Delivery Options">
+ <!ENTITY pickupOptionsLabel "Pickup Options">
+ <!ENTITY shippingGenericError "Can’t ship to this address. Select a different address.">
+ <!ENTITY deliveryGenericError "Can’t deliver to this address. Select a different address.">
+ <!ENTITY pickupGenericError "Can’t pick up from this address. Select a different address.">
+ <!ENTITY paymentMethodsLabel "Payment Method">
+ <!ENTITY address.fieldSeparator ", ">
+ <!ENTITY address.addLink.label "Add">
+ <!ENTITY address.editLink.label "Edit">
+ <!ENTITY basicCard.addLink.label "Add">
+ <!ENTITY basicCard.editLink.label "Edit">
+ <!ENTITY payer.addLink.label "Add">
+ <!ENTITY payer.editLink.label "Edit">
+ <!ENTITY shippingAddress.addPage.title "Add Shipping Address">
+ <!ENTITY shippingAddress.editPage.title "Edit Shipping Address">
+ <!ENTITY deliveryAddress.addPage.title "Add Delivery Address">
+ <!ENTITY deliveryAddress.editPage.title "Edit Delivery Address">
+ <!ENTITY pickupAddress.addPage.title "Add Pickup Address">
+ <!ENTITY pickupAddress.editPage.title "Edit Pickup Address">
+ <!ENTITY billingAddress.addPage.title "Add Billing Address">
+ <!ENTITY billingAddress.editPage.title "Edit Billing Address">
+ <!ENTITY basicCard.addPage.title "Add Credit Card">
+ <!ENTITY basicCard.editPage.title "Edit Credit Card">
+ <!ENTITY basicCard.csc.placeholder "CVV">
+ <!ENTITY basicCard.csc.back.infoTooltip "3 digit number found on the back of your credit card.">
+ <!ENTITY basicCard.csc.front.infoTooltip "3 digit number found on the front of your credit card.">
+ <!ENTITY payer.addPage.title "Add Payer Contact">
+ <!ENTITY payer.editPage.title "Edit Payer Contact">
+ <!ENTITY payerLabel "Contact Information">
+ <!ENTITY manageInPreferences "Manage saved address and credit card information in <a>&brandShortName; Preferences</a>.">
+ <!ENTITY manageInOptions "Manage saved address and credit card information in <a>&brandShortName; Options</a>.">
+ <!ENTITY cancelPaymentButton.label "Cancel">
+ <!ENTITY approvePaymentButton.label "Pay">
+ <!ENTITY processingPaymentButton.label "Processing">
+ <!ENTITY successPaymentButton.label "Done">
+ <!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.addressAddLink.label "Add">
+ <!ENTITY basicCardPage.addressEditLink.label "Edit">
+ <!ENTITY basicCardPage.backButton.label "Back">
+ <!ENTITY basicCardPage.nextButton.label "Next">
+ <!ENTITY basicCardPage.updateButton.label "Update">
+ <!ENTITY basicCardPage.persistCheckbox.label "Save credit card to &brandShortName; (CVV will not be saved)">
+ <!ENTITY basicCardPage.persistCheckbox.infoTooltip "&brandShortName; can securely store your credit card information to use in forms like this, so you don’t have to enter it every time.">
+ <!ENTITY addressPage.error.genericSave "There was an error saving the address.">
+ <!ENTITY addressPage.cancelButton.label "Cancel">
+ <!ENTITY addressPage.backButton.label "Back">
+ <!ENTITY addressPage.nextButton.label "Next">
+ <!ENTITY addressPage.updateButton.label "Update">
+ <!ENTITY addressPage.persistCheckbox.label "Save address to &brandShortName;">
+ <!ENTITY addressPage.persistCheckbox.infoTooltip "&brandShortName; can add your address to forms like this, so you don’t have to type it every time.">
+ <!ENTITY failErrorPage.title "We couldn’t complete your payment to **host-name**">
+ <!ENTITY failErrorPage.suggestionHeading "The most likely cause is a hiccup with your credit card.">
+ <!ENTITY failErrorPage.suggestion1 "Make sure the card you’re using hasn’t expired">
+ <!ENTITY failErrorPage.suggestion2 "Double check the card number and expiration date">
+ <!ENTITY failErrorPage.suggestion3 "If your credit card information is correct, contact the merchant for more information">
+ <!ENTITY failErrorPage.doneButton.label "Close">
+ <!ENTITY timeoutErrorPage.title "**host-name** is taking too long to respond.">
+ <!ENTITY timeoutErrorPage.suggestionHeading "The most likely cause is a temporary connection hiccup. Open a new tab to check your network connection or click “Close” to try again.">
+ <!ENTITY timeoutErrorPage.doneButton.label "Close">
+ <!ENTITY webPaymentsBranding.label "&brandShortName; Checkout">
+ <!ENTITY invalidOption.label "Missing or invalid information">
+ <!ENTITY acceptedCards.label "Merchant accepts:">
+]>
+<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:"/>
+
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"/>
+ <link rel="stylesheet" href="chrome://formautofill/content/skin/editDialog-shared.css"/>
+ <link rel="stylesheet" href="chrome://formautofill/content/skin/editAddress.css"/>
+ <link rel="stylesheet" href="chrome://formautofill/content/skin/editCreditCard.css"/>
+ <link rel="stylesheet" href="chrome://formautofill/content/skin/editDialog.css"/>
+ <link rel="stylesheet" href="paymentRequest.css"/>
+ <link rel="stylesheet" href="components/rich-select.css"/>
+ <link rel="stylesheet" href="components/address-option.css"/>
+ <link rel="stylesheet" href="components/basic-card-option.css"/>
+ <link rel="stylesheet" href="components/shipping-option.css"/>
+ <link rel="stylesheet" href="components/payment-details-item.css"/>
+ <link rel="stylesheet" href="components/accepted-cards.css"/>
+ <link rel="stylesheet" href="containers/address-form.css"/>
+ <link rel="stylesheet" href="containers/basic-card-form.css"/>
+ <link rel="stylesheet" href="containers/order-details.css"/>
+ <link rel="stylesheet" href="containers/rich-picker.css"/>
+ <link rel="stylesheet" href="containers/error-page.css"/>
+
+ <script src="unprivileged-fallbacks.js"></script>
+
+ <script src="formautofill/autofillEditForms.js"></script>
+
+ <script type="module" src="containers/payment-dialog.js"></script>
+ <script type="module" src="paymentRequest.js"></script>
+
+ <template id="payment-dialog-template">
+ <header>
+ <div class="page-error"
+ data-shipping-generic-error="&shippingGenericError;"
+ data-delivery-generic-error="&deliveryGenericError;"
+ data-pickup-generic-error="&pickupGenericError;"
+ aria-live="polite"></div>
+ <div id="total">
+ <currency-amount display-code="display-code"></currency-amount>
+ <div>&header.payTo; <span id="host-name"></span></div>
+ </div>
+ <div id="top-buttons" hidden="hidden">
+ <button id="view-all" class="closed">&viewAllItems;</button>
+ </div>
+ </header>
+
+ <div id="main-container">
+ <payment-request-page id="payment-summary">
+ <div class="page-body">
+ <address-picker class="shipping-related"
+ data-add-link-label="&address.addLink.label;"
+ data-edit-link-label="&address.editLink.label;"
+ data-field-separator="&address.fieldSeparator;"
+ data-shipping-address-label="&shippingAddressLabel;"
+ data-delivery-address-label="&deliveryAddressLabel;"
+ data-pickup-address-label="&pickupAddressLabel;"
+ data-invalid-label="&invalidOption.label;"
+ selected-state-key="selectedShippingAddress"></address-picker>
+
+ <shipping-option-picker class="shipping-related"
+ data-shipping-options-label="&shippingOptionsLabel;"
+ data-delivery-options-label="&deliveryOptionsLabel;"
+ data-pickup-options-label="&pickupOptionsLabel;"></shipping-option-picker>
+
+ <payment-method-picker selected-state-key="selectedPaymentCard"
+ data-add-link-label="&basicCard.addLink.label;"
+ data-edit-link-label="&basicCard.editLink.label;"
+ data-csc-placeholder="&basicCard.csc.placeholder;"
+ data-csc-back-tooltip="&basicCard.csc.back.infoTooltip;"
+ data-csc-front-tooltip="&basicCard.csc.front.infoTooltip;"
+ data-invalid-label="&invalidOption.label;"
+ label="&paymentMethodsLabel;">
+ </payment-method-picker>
+ <accepted-cards hidden="hidden" label="&acceptedCards.label;"></accepted-cards>
+ <address-picker class="payer-related"
+ label="&payerLabel;"
+ data-add-link-label="&payer.addLink.label;"
+ data-edit-link-label="&payer.editLink.label;"
+ data-field-separator="&address.fieldSeparator;"
+ data-invalid-label="&invalidOption.label;"
+ selected-state-key="selectedPayerAddress"></address-picker>
+
+ <p class="manage-text">
+ <span hidden="hidden" data-os="mac">&manageInPreferences;</span>
+ <span hidden="hidden">&manageInOptions;</span>
+ </p>
+ </div>
+
+ <footer>
+ <span class="branding">&webPaymentsBranding.label;</span>
+ <button id="cancel">&cancelPaymentButton.label;</button>
+ <button id="pay"
+ class="primary"
+ data-label="&approvePaymentButton.label;"
+ data-processing-label="&processingPaymentButton.label;"
+ data-unknown-label="&unknownPaymentButton.label;"
+ data-success-label="&successPaymentButton.label;"></button>
+ </footer>
+ </payment-request-page>
+ <section id="order-details-overlay" hidden="hidden">
+ <h2>&orderDetailsLabel;</h2>
+ <order-details></order-details>
+ </section>
+
+ <basic-card-form id="basic-card-page"
+ data-add-basic-card-title="&basicCard.addPage.title;"
+ data-edit-basic-card-title="&basicCard.editPage.title;"
+ data-error-generic-save="&basicCardPage.error.genericSave;"
+
+ data-address-add-link-label="&basicCardPage.addressAddLink.label;"
+ data-address-edit-link-label="&basicCardPage.addressEditLink.label;"
+
+ data-invalid-address-label="&invalidOption.label;"
+ data-address-field-separator="&address.fieldSeparator;"
+ data-back-button-label="&basicCardPage.backButton.label;"
+ data-next-button-label="&basicCardPage.nextButton.label;"
+ data-update-button-label="&basicCardPage.updateButton.label;"
+ data-cancel-button-label="&cancelPaymentButton.label;"
+ data-persist-checkbox-label="&basicCardPage.persistCheckbox.label;"
+ data-persist-checkbox-info-tooltip="&basicCardPage.persistCheckbox.infoTooltip;"
+ data-csc-placeholder="&basicCard.csc.placeholder;"
+ data-csc-back-info-tooltip="&basicCard.csc.back.infoTooltip;"
+ data-csc-front-info-tooltip="&basicCard.csc.front.infoTooltip;"
+ data-accepted-cards-label="&acceptedCards.label;"
+ data-field-required-symbol="&fieldRequiredSymbol;"
+ hidden="hidden"></basic-card-form>
+
+ <address-form id="shipping-address-page"
+ data-title-add="&shippingAddress.addPage.title;"
+ data-title-edit="&shippingAddress.editPage.title;"
+ data-error-generic-save="&addressPage.error.genericSave;"
+ data-cancel-button-label="&addressPage.cancelButton.label;"
+ data-back-button-label="&addressPage.backButton.label;"
+ data-next-button-label="&addressPage.nextButton.label;"
+ data-update-button-label="&addressPage.updateButton.label;"
+ data-persist-checkbox-label="&addressPage.persistCheckbox.label;"
+ data-persist-checkbox-info-tooltip="&addressPage.persistCheckbox.infoTooltip;"
+ data-field-required-symbol="&fieldRequiredSymbol;"
+ hidden="hidden"
+ selected-state-key="selectedShippingAddress"></address-form>
+
+ <address-form id="payer-address-page"
+ data-title-add="&payer.addPage.title;"
+ data-title-edit="&payer.editPage.title;"
+ data-error-generic-save="&addressPage.error.genericSave;"
+ data-cancel-button-label="&addressPage.cancelButton.label;"
+ data-back-button-label="&addressPage.backButton.label;"
+ data-next-button-label="&addressPage.nextButton.label;"
+ data-update-button-label="&addressPage.updateButton.label;"
+ data-persist-checkbox-label="&addressPage.persistCheckbox.label;"
+ data-persist-checkbox-info-tooltip="&addressPage.persistCheckbox.infoTooltip;"
+ data-field-required-symbol="&fieldRequiredSymbol;"
+ hidden="hidden"
+ selected-state-key="selectedPayerAddress"></address-form>
+
+ <address-form id="billing-address-page"
+ data-title-add="&billingAddress.addPage.title;"
+ data-title-edit="&billingAddress.editPage.title;"
+ data-error-generic-save="&addressPage.error.genericSave;"
+ data-cancel-button-label="&addressPage.cancelButton.label;"
+ data-back-button-label="&addressPage.backButton.label;"
+ data-next-button-label="&addressPage.nextButton.label;"
+ data-update-button-label="&addressPage.updateButton.label;"
+ data-persist-checkbox-label="&addressPage.persistCheckbox.label;"
+ data-persist-checkbox-info-tooltip="&addressPage.persistCheckbox.infoTooltip;"
+ data-field-required-symbol="&fieldRequiredSymbol;"
+ hidden="hidden"
+ selected-state-key="basic-card-page|billingAddressGUID"></address-form>
+
+ <completion-error-page id="completion-timeout-error" class="illustrated"
+ data-page-title="&timeoutErrorPage.title;"
+ data-suggestion-heading="&timeoutErrorPage.suggestionHeading;"
+ data-branding-label="&webPaymentsBranding.label;"
+ data-done-button-label="&timeoutErrorPage.doneButton.label;"
+ hidden="hidden"></completion-error-page>
+ <completion-error-page id="completion-fail-error" class="illustrated"
+ data-page-title="&failErrorPage.title;"
+ data-suggestion-heading="&failErrorPage.suggestionHeading;"
+ data-suggestion-1="&failErrorPage.suggestion1;"
+ data-suggestion-2="&failErrorPage.suggestion2;"
+ data-suggestion-3="&failErrorPage.suggestion3;"
+ data-branding-label="&webPaymentsBranding.label;"
+ data-done-button-label="&failErrorPage.doneButton.label;"
+ hidden="hidden"></completion-error-page>
+ </div>
+
+ <div id="disabled-overlay" hidden="hidden">
+ <!-- overlay to prevent changes while waiting for a response from the merchant -->
+ </div>
+ </template>
+
+ <template id="order-details-template">
+ <ul class="main-list"></ul>
+ <ul class="footer-items-list"></ul>
+
+ <div class="details-total">
+ <h2 class="label">&orderTotalLabel;</h2>
+ <currency-amount></currency-amount>
+ </div>
+ </template>
+</head>
+<body dir="&locale.dir;">
+ <iframe id="debugging-console"
+ hidden="hidden">
+ </iframe>
+ <payment-dialog data-shipping-address-title-add="&shippingAddress.addPage.title;"
+ data-shipping-address-title-edit="&shippingAddress.editPage.title;"
+ data-delivery-address-title-add="&deliveryAddress.addPage.title;"
+ data-delivery-address-title-edit="&deliveryAddress.editPage.title;"
+ data-pickup-address-title-add="&pickupAddress.addPage.title;"
+ data-pickup-address-title-edit="&pickupAddress.editPage.title;"
+ data-billing-address-title-add="&billingAddress.addPage.title;"
+ data-payer-title-add="&payer.addPage.title;"
+ data-payer-title-edit="&payer.editPage.title;"></payment-dialog>
+</body>
+</html>
diff --git a/browser/components/payments/res/unprivileged-fallbacks.js b/browser/components/payments/res/unprivileged-fallbacks.js
new file mode 100644
index 0000000000..ee7e47a7df
--- /dev/null
+++ b/browser/components/payments/res/unprivileged-fallbacks.js
@@ -0,0 +1,159 @@
+/* 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/. */
+
+/**
+ * This file defines fallback objects to be used during development outside
+ * of the paymentDialogWrapper. When loaded in the wrapper, a frame script
+ * overwrites these methods. Since these methods need to get overwritten in the
+ * global scope, it can't be converted into an ES module.
+ */
+
+/* eslint-disable no-console */
+/* exported log, PaymentDialogUtils */
+
+"use strict";
+
+var log = {
+ error: console.error.bind(console, "paymentRequest.xhtml:"),
+ warn: console.warn.bind(console, "paymentRequest.xhtml:"),
+ info: console.info.bind(console, "paymentRequest.xhtml:"),
+ debug: console.debug.bind(console, "paymentRequest.xhtml:"),
+};
+
+var PaymentDialogUtils = {
+ getAddressLabel(address, addressFields = null) {
+ if (addressFields) {
+ let requestedFields = addressFields.trim().split(/\s+/);
+ return (
+ requestedFields
+ .filter(f => f && address[f])
+ .map(f => address[f])
+ .join(", ") + ` (${address.guid})`
+ );
+ }
+ return `${address.name} (${address.guid})`;
+ },
+
+ getCreditCardNetworks() {
+ // Shim for list of known and supported credit card network ids as exposed by
+ // toolkit/modules/CreditCard.jsm
+ return [
+ "amex",
+ "cartebancaire",
+ "diners",
+ "discover",
+ "jcb",
+ "mastercard",
+ "mir",
+ "unionpay",
+ "visa",
+ ];
+ },
+ isCCNumber(str) {
+ return !!str.replace(/[-\s]/g, "").match(/^\d{9,}$/);
+ },
+ DEFAULT_REGION: "US",
+ countries: new Map([
+ ["US", "United States"],
+ ["CA", "Canada"],
+ ["DE", "Germany"],
+ ]),
+ getFormFormat(country) {
+ if (country == "DE") {
+ return {
+ addressLevel3Label: "suburb",
+ addressLevel2Label: "city",
+ addressLevel1Label: "province",
+ addressLevel1Options: null,
+ postalCodeLabel: "postalCode",
+ fieldsOrder: [
+ {
+ fieldId: "name",
+ newLine: true,
+ },
+ {
+ fieldId: "organization",
+ newLine: true,
+ },
+ {
+ fieldId: "street-address",
+ newLine: true,
+ },
+ { fieldId: "postal-code" },
+ { fieldId: "address-level2" },
+ ],
+ postalCodePattern: "\\d{5}",
+ countryRequiredFields: [
+ "street-address",
+ "address-level2",
+ "postal-code",
+ ],
+ };
+ }
+
+ let addressLevel1Options = null;
+ if (country == "US") {
+ addressLevel1Options = new Map([
+ ["CA", "California"],
+ ["MA", "Massachusetts"],
+ ["MI", "Michigan"],
+ ]);
+ } else if (country == "CA") {
+ addressLevel1Options = new Map([
+ ["NS", "Nova Scotia"],
+ ["ON", "Ontario"],
+ ["YT", "Yukon"],
+ ]);
+ }
+
+ let fieldsOrder = [
+ { fieldId: "name", newLine: true },
+ { fieldId: "street-address", newLine: true },
+ { fieldId: "address-level2" },
+ { fieldId: "address-level1" },
+ { fieldId: "postal-code" },
+ { fieldId: "organization" },
+ ];
+ if (country == "BR") {
+ fieldsOrder.splice(2, 0, { fieldId: "address-level3" });
+ }
+
+ return {
+ addressLevel3Label: "suburb",
+ addressLevel2Label: "city",
+ addressLevel1Label: country == "US" ? "state" : "province",
+ addressLevel1Options,
+ postalCodeLabel: country == "US" ? "zip" : "postalCode",
+ fieldsOrder,
+ // The following values come from addressReferences.js and should not be changed.
+ /* eslint-disable-next-line max-len */
+ postalCodePattern:
+ country == "US"
+ ? "(\\d{5})(?:[ \\-](\\d{4}))?"
+ : "[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d",
+ countryRequiredFields:
+ country == "US" || country == "CA"
+ ? [
+ "street-address",
+ "address-level2",
+ "address-level1",
+ "postal-code",
+ ]
+ : ["street-address", "address-level2", "postal-code"],
+ };
+ },
+ findAddressSelectOption(selectEl, address, fieldName) {
+ return null;
+ },
+ getDefaultPreferences() {
+ let prefValues = {
+ saveCreditCardDefaultChecked: false,
+ saveAddressDefaultChecked: true,
+ };
+ return prefValues;
+ },
+ isOfficialBranding() {
+ return false;
+ },
+};
diff --git a/browser/components/payments/server.py b/browser/components/payments/server.py
new file mode 100644
index 0000000000..7cb8380260
--- /dev/null
+++ b/browser/components/payments/server.py
@@ -0,0 +1,23 @@
+# 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/.
+
+from __future__ import absolute_import
+import BaseHTTPServer
+from SimpleHTTPServer import SimpleHTTPRequestHandler
+
+
+class RequestHandler(SimpleHTTPRequestHandler, object):
+ def translate_path(self, path):
+ # Map autofill paths to their own directory
+ autofillPath = "/formautofill"
+ if path.startswith(autofillPath):
+ path = "browser/extensions/formautofill/content" + path[len(autofillPath) :]
+ else:
+ path = "browser/components/payments/res" + path
+
+ return super(RequestHandler, self).translate_path(path)
+
+
+if __name__ == "__main__":
+ BaseHTTPServer.test(RequestHandler, BaseHTTPServer.HTTPServer)
diff --git a/browser/components/payments/test/PaymentTestUtils.jsm b/browser/components/payments/test/PaymentTestUtils.jsm
new file mode 100644
index 0000000000..fd20d834e5
--- /dev/null
+++ b/browser/components/payments/test/PaymentTestUtils.jsm
@@ -0,0 +1,612 @@
+"use strict";
+
+/* global info */
+
+var EXPORTED_SYMBOLS = ["PaymentTestUtils"];
+
+var PaymentTestUtils = {
+ /**
+ * Common content tasks functions to be used with ContentTask.spawn.
+ */
+ ContentTasks: {
+ /* eslint-env mozilla/frame-script */
+ /**
+ * Add a completion handler to the existing `showPromise` to call .complete().
+ * @returns {Object} representing the PaymentResponse
+ */
+ addCompletionHandler: async ({ result, delayMs = 0 }) => {
+ let response = await content.showPromise;
+ let completeException;
+
+ // delay the given # milliseconds
+ await new Promise(resolve => content.setTimeout(resolve, delayMs));
+
+ try {
+ await response.complete(result);
+ } catch (ex) {
+ info(`Complete error: ${ex}`);
+ completeException = {
+ name: ex.name,
+ message: ex.message,
+ };
+ }
+ return {
+ completeException,
+ response: response.toJSON(),
+ // XXX: Bug NNN: workaround for `details` not being included in `toJSON`.
+ methodDetails: response.details,
+ };
+ },
+
+ /**
+ * Add a retry handler to the existing `showPromise` to call .retry().
+ * @returns {Object} representing the PaymentResponse
+ */
+ addRetryHandler: async ({ validationErrors, delayMs = 0 }) => {
+ let response = await content.showPromise;
+ let retryException;
+
+ // delay the given # milliseconds
+ await new Promise(resolve => content.setTimeout(resolve, delayMs));
+
+ try {
+ await response.retry(Cu.cloneInto(validationErrors, content));
+ } catch (ex) {
+ info(`Retry error: ${ex}`);
+ retryException = {
+ name: ex.name,
+ message: ex.message,
+ };
+ }
+ return {
+ retryException,
+ response: response.toJSON(),
+ // XXX: Bug NNN: workaround for `details` not being included in `toJSON`.
+ methodDetails: response.details,
+ };
+ },
+
+ ensureNoPaymentRequestEvent: ({ eventName }) => {
+ content.rq.addEventListener(eventName, event => {
+ ok(false, `Unexpected ${eventName}`);
+ });
+ },
+
+ promisePaymentRequestEvent: ({ eventName }) => {
+ content[eventName + "Promise"] = new Promise(resolve => {
+ content.rq.addEventListener(eventName, () => {
+ info(`Received event: ${eventName}`);
+ resolve();
+ });
+ });
+ },
+
+ /**
+ * Used for PaymentRequest and PaymentResponse event promises.
+ */
+ awaitPaymentEventPromise: async ({ eventName }) => {
+ await content[eventName + "Promise"];
+ return true;
+ },
+
+ promisePaymentResponseEvent: async ({ eventName }) => {
+ let response = await content.showPromise;
+ content[eventName + "Promise"] = new Promise(resolve => {
+ response.addEventListener(eventName, () => {
+ info(`Received event: ${eventName}`);
+ resolve();
+ });
+ });
+ },
+
+ updateWith: async ({ eventName, details }) => {
+ /* globals ok */
+ if (
+ details.error &&
+ (!details.shippingOptions || details.shippingOptions.length)
+ ) {
+ ok(false, "Need to clear the shipping options to show error text");
+ }
+ if (!details.total) {
+ ok(
+ false,
+ "`total: { label, amount: { value, currency } }` is required for updateWith"
+ );
+ }
+
+ content[eventName + "Promise"] = new Promise(resolve => {
+ content.rq.addEventListener(
+ eventName,
+ event => {
+ event.updateWith(details);
+ resolve();
+ },
+ { once: true }
+ );
+ });
+ },
+
+ /**
+ * Create a new payment request cached as `rq` and then show it.
+ *
+ * @param {Object} args
+ * @param {PaymentMethodData[]} methodData
+ * @param {PaymentDetailsInit} details
+ * @param {PaymentOptions} options
+ * @returns {Object}
+ */
+ createAndShowRequest: ({ methodData, details, options }) => {
+ const rq = new content.PaymentRequest(
+ Cu.cloneInto(methodData, content),
+ details,
+ options
+ );
+ content.rq = rq; // assign it so we can retrieve it later
+
+ const handle = content.windowUtils.setHandlingUserInput(true);
+ content.showPromise = rq.show();
+
+ handle.destruct();
+ return {
+ requestId: rq.id,
+ };
+ },
+ },
+
+ DialogContentTasks: {
+ getShippingOptions: () => {
+ let picker = content.document.querySelector("shipping-option-picker");
+ let popupBox = Cu.waiveXrays(picker).dropdown.popupBox;
+ let selectedOptionIndex = popupBox.selectedIndex;
+ let selectedOption = Cu.waiveXrays(picker).dropdown.selectedOption;
+
+ let result = {
+ optionCount: popupBox.children.length,
+ selectedOptionIndex,
+ };
+ if (!selectedOption) {
+ return result;
+ }
+
+ return Object.assign(result, {
+ selectedOptionID: selectedOption.getAttribute("value"),
+ selectedOptionLabel: selectedOption.getAttribute("label"),
+ selectedOptionCurrency: selectedOption.getAttribute("amount-currency"),
+ selectedOptionValue: selectedOption.getAttribute("amount-value"),
+ });
+ },
+
+ getShippingAddresses: () => {
+ let doc = content.document;
+ let addressPicker = doc.querySelector(
+ "address-picker[selected-state-key='selectedShippingAddress']"
+ );
+ let popupBox = Cu.waiveXrays(addressPicker).dropdown.popupBox;
+ let options = Array.from(popupBox.children).map(option => {
+ return {
+ guid: option.getAttribute("guid"),
+ country: option.getAttribute("country"),
+ selected: option.selected,
+ };
+ });
+ let selectedOptionIndex = options.findIndex(item => item.selected);
+ return {
+ selectedOptionIndex,
+ options,
+ };
+ },
+
+ selectShippingAddressByCountry: country => {
+ let doc = content.document;
+ let addressPicker = doc.querySelector(
+ "address-picker[selected-state-key='selectedShippingAddress']"
+ );
+ let select = Cu.waiveXrays(addressPicker).dropdown.popupBox;
+ let option = select.querySelector(`[country="${country}"]`);
+ content.fillField(select, option.value);
+ },
+
+ selectPayerAddressByGuid: guid => {
+ let doc = content.document;
+ let addressPicker = doc.querySelector(
+ "address-picker[selected-state-key='selectedPayerAddress']"
+ );
+ let select = Cu.waiveXrays(addressPicker).dropdown.popupBox;
+ content.fillField(select, guid);
+ },
+
+ selectShippingAddressByGuid: guid => {
+ let doc = content.document;
+ let addressPicker = doc.querySelector(
+ "address-picker[selected-state-key='selectedShippingAddress']"
+ );
+ let select = Cu.waiveXrays(addressPicker).dropdown.popupBox;
+ content.fillField(select, guid);
+ },
+
+ selectShippingOptionById: value => {
+ let doc = content.document;
+ let optionPicker = doc.querySelector("shipping-option-picker");
+ let select = Cu.waiveXrays(optionPicker).dropdown.popupBox;
+ content.fillField(select, value);
+ },
+
+ selectPaymentOptionByGuid: guid => {
+ let doc = content.document;
+ let methodPicker = doc.querySelector("payment-method-picker");
+ let select = Cu.waiveXrays(methodPicker).dropdown.popupBox;
+ content.fillField(select, guid);
+ },
+
+ /**
+ * Click the primary button for the current page
+ *
+ * Don't await on this method from a ContentTask when expecting the dialog to close
+ *
+ * @returns {undefined}
+ */
+ clickPrimaryButton: () => {
+ let { requestStore } = Cu.waiveXrays(
+ content.document.querySelector("payment-dialog")
+ );
+ let { page } = requestStore.getState();
+ let button = content.document.querySelector(`#${page.id} button.primary`);
+ ok(
+ !button.disabled,
+ `#${page.id} primary button should not be disabled when clicking it`
+ );
+ button.click();
+ },
+
+ /**
+ * Click the cancel button
+ *
+ * Don't await on this task since the cancel can close the dialog before
+ * ContentTask can resolve the promise.
+ *
+ * @returns {undefined}
+ */
+ manuallyClickCancel: () => {
+ content.document.getElementById("cancel").click();
+ },
+
+ /**
+ * Do the minimum possible to complete the payment succesfully.
+ *
+ * Don't await on this task since the cancel can close the dialog before
+ * ContentTask can resolve the promise.
+ *
+ * @returns {undefined}
+ */
+ completePayment: async () => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "Wait for change to payment-summary before clicking Pay"
+ );
+
+ let button = content.document.getElementById("pay");
+ ok(
+ !button.disabled,
+ "Pay button should not be disabled when clicking it"
+ );
+ button.click();
+ },
+
+ setSecurityCode: ({ securityCode }) => {
+ // 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)
+ .querySelector("input")
+ .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();
+ },
+
+ getCurrentState: async content => {
+ let { requestStore } = Cu.waiveXrays(
+ content.document.querySelector("payment-dialog")
+ );
+ return requestStore.getState();
+ },
+ },
+
+ /**
+ * Common PaymentMethodData for testing
+ */
+ MethodData: {
+ basicCard: {
+ supportedMethods: "basic-card",
+ },
+ bobPay: {
+ supportedMethods: "https://www.example.com/bobpay",
+ },
+ },
+
+ /**
+ * Common PaymentDetailsInit for testing
+ */
+ Details: {
+ total2USD: {
+ total: {
+ label: "Total due",
+ amount: { currency: "USD", value: "2.00" },
+ },
+ },
+ total32USD: {
+ total: {
+ label: "Total due",
+ amount: { currency: "USD", value: "32.00" },
+ },
+ },
+ total60USD: {
+ total: {
+ label: "Total due",
+ amount: { currency: "USD", value: "60.00" },
+ },
+ },
+ total1pt75EUR: {
+ total: {
+ label: "Total due",
+ amount: { currency: "EUR", value: "1.75" },
+ },
+ },
+ total60EUR: {
+ total: {
+ label: "Total due",
+ amount: { currency: "EUR", value: "75.00" },
+ },
+ },
+ twoDisplayItems: {
+ displayItems: [
+ {
+ label: "First",
+ amount: { currency: "USD", value: "1" },
+ },
+ {
+ label: "Second",
+ amount: { currency: "USD", value: "2" },
+ },
+ ],
+ },
+ twoDisplayItemsEUR: {
+ displayItems: [
+ {
+ label: "First",
+ amount: { currency: "EUR", value: "0.85" },
+ },
+ {
+ label: "Second",
+ amount: { currency: "EUR", value: "1.70" },
+ },
+ ],
+ },
+ twoShippingOptions: {
+ shippingOptions: [
+ {
+ id: "1",
+ label: "Meh Unreliable Shipping",
+ amount: { currency: "USD", value: "1" },
+ },
+ {
+ id: "2",
+ label: "Premium Slow Shipping",
+ amount: { currency: "USD", value: "2" },
+ selected: true,
+ },
+ ],
+ },
+ twoShippingOptionsEUR: {
+ shippingOptions: [
+ {
+ id: "1",
+ label: "Sloth Shipping",
+ amount: { currency: "EUR", value: "1.01" },
+ },
+ {
+ id: "2",
+ label: "Velociraptor Shipping",
+ amount: { currency: "EUR", value: "63545.65" },
+ selected: true,
+ },
+ ],
+ },
+ noShippingOptions: {
+ shippingOptions: [],
+ },
+ bobPayPaymentModifier: {
+ modifiers: [
+ {
+ additionalDisplayItems: [
+ {
+ label: "Credit card fee",
+ amount: { currency: "USD", value: "0.50" },
+ },
+ ],
+ supportedMethods: "basic-card",
+ total: {
+ label: "Total due",
+ amount: { currency: "USD", value: "2.50" },
+ },
+ data: {},
+ },
+ {
+ additionalDisplayItems: [
+ {
+ label: "Bob-pay fee",
+ amount: { currency: "USD", value: "1.50" },
+ },
+ ],
+ supportedMethods: "https://www.example.com/bobpay",
+ total: {
+ label: "Total due",
+ amount: { currency: "USD", value: "3.50" },
+ },
+ },
+ ],
+ },
+ additionalDisplayItemsEUR: {
+ modifiers: [
+ {
+ additionalDisplayItems: [
+ {
+ label: "Handling fee",
+ amount: { currency: "EUR", value: "1.00" },
+ },
+ ],
+ supportedMethods: "basic-card",
+ total: {
+ label: "Total due",
+ amount: { currency: "EUR", value: "2.50" },
+ },
+ },
+ ],
+ },
+ noError: {
+ error: "",
+ },
+ genericShippingError: {
+ error: "Cannot ship with option 1 on days that end with Y",
+ },
+ fieldSpecificErrors: {
+ error: "There are errors related to specific parts of the address",
+ shippingAddressErrors: {
+ addressLine:
+ "Can only ship to ROADS, not DRIVES, BOULEVARDS, or STREETS",
+ city: "Can only ship to CITIES, not TOWNSHIPS or VILLAGES",
+ country: "Can only ship to USA, not CA",
+ dependentLocality: "Can only be SUBURBS, not NEIGHBORHOODS",
+ organization: "Can only ship to CORPORATIONS, not CONSORTIUMS",
+ phone: "Only allowed to ship to area codes that start with 9",
+ postalCode: "Only allowed to ship to postalCodes that start with 0",
+ recipient: "Can only ship to names that start with J",
+ region: "Can only ship to regions that start with M",
+ regionCode:
+ "Regions must be 1 to 3 characters in length (sometimes ;) )",
+ },
+ },
+ },
+
+ Options: {
+ requestShippingOption: {
+ requestShipping: true,
+ },
+ requestPayerNameAndEmail: {
+ requestPayerName: true,
+ requestPayerEmail: true,
+ },
+ requestPayerNameEmailAndPhone: {
+ requestPayerName: true,
+ requestPayerEmail: true,
+ requestPayerPhone: true,
+ },
+ },
+
+ Addresses: {
+ TimBR: {
+ "given-name": "Timothy",
+ "additional-name": "João",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "Rua Adalberto Pajuaba, 404",
+ "address-level3": "Campos Elísios",
+ "address-level2": "Ribeirão Preto",
+ "address-level1": "SP",
+ "postal-code": "14055-220",
+ country: "BR",
+ tel: "+0318522222222",
+ email: "timbr@example.org",
+ },
+ TimBL: {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+16172535702",
+ email: "timbl@example.org",
+ },
+ TimBL2: {
+ "given-name": "Timothy",
+ "additional-name": "Johann",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "1 Pommes Frittes Place",
+ "address-level2": "Berlin",
+ // address-level1 isn't used in our forms for Germany
+ "postal-code": "02138",
+ country: "DE",
+ tel: "+16172535702",
+ email: "timbl@example.com",
+ },
+ /* Used as a temporary (not persisted in autofill storage) address in tests */
+ Temp: {
+ "given-name": "Temp",
+ "family-name": "McTempFace",
+ organization: "Temps Inc.",
+ "street-address": "1a Temporary Ave.",
+ "address-level2": "Temp Town",
+ "address-level1": "CA",
+ "postal-code": "31337",
+ country: "US",
+ tel: "+15032541000",
+ email: "tempie@example.com",
+ },
+ },
+
+ BasicCards: {
+ JohnDoe: {
+ "cc-exp-month": 1,
+ "cc-exp-year": new Date().getFullYear() + 9,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-type": "visa",
+ },
+ JaneMasterCard: {
+ "cc-exp-month": 12,
+ "cc-exp-year": new Date().getFullYear() + 9,
+ "cc-name": "Jane McMaster-Card",
+ "cc-number": "5555555555554444",
+ "cc-type": "mastercard",
+ },
+ MissingFields: {
+ "cc-name": "Missy Fields",
+ "cc-number": "340000000000009",
+ },
+ Temp: {
+ "cc-exp-month": 12,
+ "cc-exp-year": new Date().getFullYear() + 9,
+ "cc-name": "Temp Name",
+ "cc-number": "5105105105105100",
+ "cc-type": "mastercard",
+ },
+ },
+};
diff --git a/browser/components/payments/test/browser/blank_page.html b/browser/components/payments/test/browser/blank_page.html
new file mode 100644
index 0000000000..2a42a20e6e
--- /dev/null
+++ b/browser/components/payments/test/browser/blank_page.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <title>Blank page</title>
+ </head>
+ <body>
+ BLANK PAGE
+ </body>
+</html>
diff --git a/browser/components/payments/test/browser/browser.ini b/browser/components/payments/test/browser/browser.ini
new file mode 100644
index 0000000000..241d122446
--- /dev/null
+++ b/browser/components/payments/test/browser/browser.ini
@@ -0,0 +1,34 @@
+[DEFAULT]
+head = head.js
+prefs =
+ browser.pagethumbnails.capturing_disabled=true
+ dom.payments.request.enabled=true
+ extensions.formautofill.creditCards.available=true
+ extensions.formautofill.reauth.enabled=true
+skip-if = true || !e10s # Bug 1515048 - Disable for now. Bug 1365964 - Payment Request isn't implemented for non-e10s
+support-files =
+ blank_page.html
+
+[browser_address_edit.js]
+skip-if = verify && debug && os == 'mac'
+[browser_address_edit_hidden_fields.js]
+[browser_card_edit.js]
+skip-if = debug && (os == 'mac' || os == 'linux') # bug 1465673
+[browser_change_shipping.js]
+[browser_dropdowns.js]
+[browser_host_name.js]
+[browser_onboarding_wizard.js]
+[browser_openPreferences.js]
+[browser_payerRequestedFields.js]
+[browser_payment_completion.js]
+[browser_profile_storage.js]
+[browser_request_serialization.js]
+[browser_request_shipping.js]
+[browser_retry.js]
+[browser_retry_fieldErrors.js]
+[browser_shippingaddresschange_error.js]
+[browser_show_dialog.js]
+skip-if = os == 'win' && debug # bug 1418385
+[browser_tab_modal.js]
+skip-if = os == 'linux' && !debug # bug 1508577
+[browser_total.js]
diff --git a/browser/components/payments/test/browser/browser_address_edit.js b/browser/components/payments/test/browser/browser_address_edit.js
new file mode 100644
index 0000000000..6d214b086f
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_address_edit.js
@@ -0,0 +1,1029 @@
+/* eslint-disable no-shadow */
+
+"use strict";
+
+async function setup() {
+ await setupFormAutofillStorage();
+ await cleanupFormAutofillStorage();
+ // add an address and card to avoid the FTU sequence
+ let prefilledGuids = await addSampleAddressesAndBasicCard(
+ [PTU.Addresses.TimBL],
+ [PTU.BasicCards.JohnDoe]
+ );
+
+ info("associating the card with the billing address");
+ await formAutofillStorage.creditCards.update(
+ prefilledGuids.card1GUID,
+ {
+ billingAddressGUID: prefilledGuids.address1GUID,
+ },
+ true
+ );
+
+ return prefilledGuids;
+}
+
+/*
+ * Test that we can correctly add an address and elect for it to be saved or temporary
+ */
+add_task(async function test_add_link() {
+ let prefilledGuids = await setup();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ info("setup got prefilledGuids: " + JSON.stringify(prefilledGuids));
+ await spawnPaymentDialogTask(frame, async () => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let {
+ tempAddresses,
+ savedAddresses,
+ } = await PTU.DialogContentUtils.getCurrentState(content);
+ is(
+ Object.keys(tempAddresses).length,
+ 0,
+ "No temporary addresses at the start of test"
+ );
+ is(
+ Object.keys(savedAddresses).length,
+ 1,
+ "1 saved address at the start of test"
+ );
+ });
+
+ let testOptions = [
+ { setPersistCheckedValue: true, expectPersist: true },
+ { setPersistCheckedValue: false, expectPersist: false },
+ ];
+ let newAddress = Object.assign({}, PTU.Addresses.TimBL2);
+ // Emails aren't part of shipping addresses
+ delete newAddress.email;
+
+ for (let options of testOptions) {
+ let shippingAddressChangePromise = SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ await manuallyAddShippingAddress(frame, newAddress, options);
+ await shippingAddressChangePromise;
+ info("got shippingaddresschange event");
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ({ address, options, prefilledGuids }) => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let newAddresses = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.tempAddresses && state.savedAddresses;
+ }
+ );
+ let colnName = options.expectPersist
+ ? "savedAddresses"
+ : "tempAddresses";
+ // remove any pre-filled entries
+ delete newAddresses[colnName][prefilledGuids.address1GUID];
+
+ let addressGUIDs = Object.keys(newAddresses[colnName]);
+ is(addressGUIDs.length, 1, "Check there is one address");
+ let resultAddress = newAddresses[colnName][addressGUIDs[0]];
+ for (let [key, val] of Object.entries(address)) {
+ is(resultAddress[key], val, "Check " + key);
+ }
+ },
+ { address: newAddress, options, prefilledGuids }
+ );
+ }
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ await cleanupFormAutofillStorage();
+});
+
+add_task(async function test_edit_link() {
+ let prefilledGuids = await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ let shippingAddressChangePromise = SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ const EXPECTED_ADDRESS = {
+ "given-name": "Jaws",
+ "family-name": "swaJ",
+ organization: "aliizoM",
+ };
+
+ info("setup got prefilledGuids: " + JSON.stringify(prefilledGuids));
+ await spawnPaymentDialogTask(frame, async () => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let {
+ tempAddresses,
+ savedAddresses,
+ } = await PTU.DialogContentUtils.getCurrentState(content);
+ is(
+ Object.keys(tempAddresses).length,
+ 0,
+ "No temporary addresses at the start of test"
+ );
+ is(
+ Object.keys(savedAddresses).length,
+ 1,
+ "1 saved address at the start of test"
+ );
+
+ let picker = content.document.querySelector(
+ "address-picker[selected-state-key='selectedShippingAddress']"
+ );
+ Cu.waiveXrays(picker).dropdown.popupBox.focus();
+ EventUtils.synthesizeKey(
+ PTU.Addresses.TimBL["given-name"],
+ {},
+ content.window
+ );
+
+ let editLink = content.document.querySelector(
+ "address-picker .edit-link"
+ );
+ is(editLink.textContent, "Edit", "Edit link text");
+
+ editLink.click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "shipping-address-page" &&
+ !!state["shipping-address-page"].guid
+ );
+ },
+ "Check edit page state"
+ );
+
+ let addressForm = content.document.querySelector(
+ "#shipping-address-page"
+ );
+ ok(content.isVisible(addressForm), "Shipping address form is visible");
+
+ let title = addressForm.querySelector("h2");
+ is(
+ title.textContent,
+ "Edit Shipping Address",
+ "Page title should be set"
+ );
+
+ let saveButton = addressForm.querySelector(".save-button");
+ is(
+ saveButton.textContent,
+ "Update",
+ "Save button has the correct label"
+ );
+ });
+
+ let editOptions = {
+ checkboxSelector: "#shipping-address-page .persist-checkbox",
+ isEditing: true,
+ expectPersist: true,
+ };
+ await fillInShippingAddressForm(frame, EXPECTED_ADDRESS, editOptions);
+ await verifyPersistCheckbox(frame, editOptions);
+ await submitAddressForm(frame, EXPECTED_ADDRESS, editOptions);
+
+ await spawnPaymentDialogTask(
+ frame,
+ async address => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ let addresses = Object.entries(state.savedAddresses);
+ return (
+ addresses.length == 1 &&
+ addresses[0][1]["given-name"] == address["given-name"]
+ );
+ },
+ "Check address was edited"
+ );
+
+ let addressGUIDs = Object.keys(state.savedAddresses);
+ is(addressGUIDs.length, 1, "Check there is still one address");
+ let savedAddress = state.savedAddresses[addressGUIDs[0]];
+ for (let [key, val] of Object.entries(address)) {
+ is(savedAddress[key], val, "Check updated " + key);
+ }
+ ok(savedAddress.guid, "Address has a guid");
+ ok(savedAddress.name, "Address has a name");
+ ok(
+ savedAddress.name.includes(address["given-name"]) &&
+ savedAddress.name.includes(address["family-name"]),
+ "Address.name was computed"
+ );
+
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "Switched back to payment-summary"
+ );
+ },
+ EXPECTED_ADDRESS
+ );
+
+ await shippingAddressChangePromise;
+ info("got shippingaddresschange event");
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ await cleanupFormAutofillStorage();
+});
+
+add_task(async function test_add_payer_contact_name_email_link() {
+ await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ options: PTU.Options.requestPayerNameAndEmail,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ const EXPECTED_ADDRESS = {
+ "given-name": "Deraj",
+ "family-name": "Niew",
+ email: "test@example.com",
+ };
+
+ const addOptions = {
+ addLinkSelector: "address-picker.payer-related .add-link",
+ checkboxSelector: "#payer-address-page .persist-checkbox",
+ initialPageId: "payment-summary",
+ addressPageId: "payer-address-page",
+ expectPersist: true,
+ };
+
+ await spawnPaymentDialogTask(frame, async () => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let {
+ tempAddresses,
+ savedAddresses,
+ } = await PTU.DialogContentUtils.getCurrentState(content);
+ is(
+ Object.keys(tempAddresses).length,
+ 0,
+ "No temporary addresses at the start of test"
+ );
+ is(
+ Object.keys(savedAddresses).length,
+ 1,
+ "1 saved address at the start of test"
+ );
+ });
+
+ await navigateToAddAddressPage(frame, addOptions);
+
+ await spawnPaymentDialogTask(frame, async () => {
+ let addressForm = content.document.querySelector("#payer-address-page");
+ ok(content.isVisible(addressForm), "Payer address form is visible");
+
+ let title = addressForm.querySelector("address-form h2");
+ is(title.textContent, "Add Payer Contact", "Page title should be set");
+
+ let saveButton = addressForm.querySelector("address-form .save-button");
+ is(saveButton.textContent, "Next", "Save button has the correct label");
+
+ info("check that non-payer requested fields are hidden");
+ for (let selector of ["#organization", "#tel"]) {
+ let element = addressForm.querySelector(selector);
+ ok(content.isHidden(element), selector + " should be hidden");
+ }
+ });
+
+ await fillInPayerAddressForm(frame, EXPECTED_ADDRESS, addOptions);
+ await verifyPersistCheckbox(frame, addOptions);
+ await submitAddressForm(frame, EXPECTED_ADDRESS, addOptions);
+
+ await spawnPaymentDialogTask(
+ frame,
+ async address => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let { savedAddresses } = await PTU.DialogContentUtils.getCurrentState(
+ content
+ );
+
+ let addressGUIDs = Object.keys(savedAddresses);
+ is(addressGUIDs.length, 2, "Check there are now 2 addresses");
+ let savedAddress = savedAddresses[addressGUIDs[1]];
+ for (let [key, val] of Object.entries(address)) {
+ is(savedAddress[key], val, "Check " + key);
+ }
+ ok(savedAddress.guid, "Address has a guid");
+ ok(savedAddress.name, "Address has a name");
+ ok(
+ savedAddress.name.includes(address["given-name"]) &&
+ savedAddress.name.includes(address["family-name"]),
+ "Address.name was computed"
+ );
+ },
+ EXPECTED_ADDRESS
+ );
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_edit_payer_contact_name_email_phone_link() {
+ await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ options: PTU.Options.requestPayerNameEmailAndPhone,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ const EXPECTED_ADDRESS = {
+ "given-name": "Deraj",
+ "family-name": "Niew",
+ email: "test@example.com",
+ tel: "+15555551212",
+ };
+ const editOptions = {
+ checkboxSelector: "#payer-address-page .persist-checkbox",
+ initialPageId: "payment-summary",
+ addressPageId: "payer-address-page",
+ expectPersist: true,
+ isEditing: true,
+ };
+
+ await spawnPaymentDialogTask(
+ frame,
+ async address => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return Object.keys(state.savedAddresses).length == 1;
+ },
+ "One saved addresses when starting test"
+ );
+
+ let editLink = content.document.querySelector(
+ "address-picker.payer-related .edit-link"
+ );
+ is(editLink.textContent, "Edit", "Edit link text");
+
+ editLink.click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "payer-address-page" &&
+ !!state["payer-address-page"].guid
+ );
+ },
+ "Check edit page state"
+ );
+
+ let addressForm = content.document.querySelector(
+ "#payer-address-page"
+ );
+ ok(content.isVisible(addressForm), "Payer address form is visible");
+
+ let title = addressForm.querySelector("h2");
+ is(
+ title.textContent,
+ "Edit Payer Contact",
+ "Page title should be set"
+ );
+
+ let saveButton = addressForm.querySelector(".save-button");
+ is(
+ saveButton.textContent,
+ "Update",
+ "Save button has the correct label"
+ );
+
+ info("check that non-payer requested fields are hidden");
+ let formElements = addressForm.querySelectorAll(
+ ":is(input, select, textarea"
+ );
+ let allowedFields = [
+ "given-name",
+ "additional-name",
+ "family-name",
+ "email",
+ "tel",
+ ];
+ for (let element of formElements) {
+ let shouldBeVisible = allowedFields.includes(element.id);
+ if (shouldBeVisible) {
+ ok(content.isVisible(element), element.id + " should be visible");
+ } else {
+ ok(content.isHidden(element), element.id + " should be hidden");
+ }
+ }
+
+ info("overwriting field values");
+ for (let [key, val] of Object.entries(address)) {
+ let field = addressForm.querySelector(`#${key}`);
+ field.value = val + "1";
+ ok(!field.disabled, `Field #${key} shouldn't be disabled`);
+ }
+ },
+ EXPECTED_ADDRESS
+ );
+
+ await verifyPersistCheckbox(frame, editOptions);
+ await submitAddressForm(frame, EXPECTED_ADDRESS, editOptions);
+
+ await spawnPaymentDialogTask(
+ frame,
+ async address => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ let addresses = Object.entries(state.savedAddresses);
+ return (
+ addresses.length == 1 &&
+ addresses[0][1]["given-name"] == address["given-name"] + "1"
+ );
+ },
+ "Check address was edited"
+ );
+
+ let addressGUIDs = Object.keys(state.savedAddresses);
+ is(addressGUIDs.length, 1, "Check there is still one address");
+ let savedAddress = state.savedAddresses[addressGUIDs[0]];
+ for (let [key, val] of Object.entries(address)) {
+ is(savedAddress[key], val + "1", "Check updated " + key);
+ }
+ ok(savedAddress.guid, "Address has a guid");
+ ok(savedAddress.name, "Address has a name");
+ ok(
+ savedAddress.name.includes(address["given-name"]) &&
+ savedAddress.name.includes(address["family-name"]),
+ "Address.name was computed"
+ );
+ },
+ EXPECTED_ADDRESS
+ );
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_shipping_address_picker() {
+ await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function test_picker_option_label(address) {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+ const { FormAutofillUtils } = ChromeUtils.import(
+ "resource://formautofill/FormAutofillUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return Object.keys(state.savedAddresses).length == 1;
+ },
+ "One saved addresses when starting test"
+ );
+ let savedAddress = Object.values(state.savedAddresses)[0];
+
+ let selector =
+ "address-picker[selected-state-key='selectedShippingAddress']";
+ let picker = content.document.querySelector(selector);
+ let option = Cu.waiveXrays(picker).dropdown.popupBox.children[0];
+ is(
+ option.textContent,
+ FormAutofillUtils.getAddressLabel(savedAddress, null),
+ "Shows correct shipping option label"
+ );
+ }
+ );
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_payer_address_picker() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ options: PTU.Options.requestPayerNameEmailAndPhone,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function test_picker_option_label(address) {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+ const { FormAutofillUtils } = ChromeUtils.import(
+ "resource://formautofill/FormAutofillUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return Object.keys(state.savedAddresses).length == 1;
+ },
+ "One saved addresses when starting test"
+ );
+ let savedAddress = Object.values(state.savedAddresses)[0];
+
+ let selector =
+ "address-picker[selected-state-key='selectedPayerAddress']";
+ let picker = content.document.querySelector(selector);
+ let option = Cu.waiveXrays(picker).dropdown.popupBox.children[0];
+ is(
+ option.textContent.includes("32 Vassar Street"),
+ false,
+ "Payer option label does not contain street address"
+ );
+ is(
+ option.textContent,
+ FormAutofillUtils.getAddressLabel(savedAddress, "name tel email"),
+ "Shows correct payer option label"
+ );
+ }
+ );
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+/*
+ * Test that we can correctly add an address from a private window
+ */
+add_task(async function test_private_persist_addresses() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let prefilledGuids = await setup();
+
+ is(
+ (await formAutofillStorage.addresses.getAll()).length,
+ 1,
+ "Setup results in 1 stored address at start of test"
+ );
+
+ await withNewTabInPrivateWindow(
+ {
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ info("in new tab w. private window");
+ let { frame } =
+ // setupPaymentDialog from a private window.
+ await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+ info("/setupPaymentDialog");
+
+ let addressToAdd = Object.assign({}, PTU.Addresses.Temp);
+ // Emails aren't part of shipping addresses
+ delete addressToAdd.email;
+ const addOptions = {
+ addLinkSelector: "address-picker.shipping-related .add-link",
+ checkboxSelector: "#shipping-address-page .persist-checkbox",
+ initialPageId: "payment-summary",
+ addressPageId: "shipping-address-page",
+ expectPersist: false,
+ isPrivate: true,
+ };
+
+ await navigateToAddAddressPage(frame, addOptions);
+ await spawnPaymentDialogTask(frame, async () => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.getCurrentState(content);
+ info("on address-page and state.isPrivate: " + state.isPrivate);
+ ok(
+ state.isPrivate,
+ "isPrivate flag is set when paymentrequest is shown in a private session"
+ );
+ });
+
+ info("wait for initialAddresses");
+ let initialAddresses = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingAddresses
+ );
+ is(
+ initialAddresses.options.length,
+ 1,
+ "Got expected number of pre-filled shipping addresses"
+ );
+
+ await fillInShippingAddressForm(frame, addressToAdd, addOptions);
+ await verifyPersistCheckbox(frame, addOptions);
+ await submitAddressForm(frame, addressToAdd, addOptions);
+
+ let shippingAddresses = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingAddresses
+ );
+ info("shippingAddresses", shippingAddresses);
+ let addressOptions = shippingAddresses.options;
+ // expect the prefilled address + the new temporary address
+ is(
+ addressOptions.length,
+ 2,
+ "The picker has the expected number of address options"
+ );
+ let tempAddressOption = addressOptions.find(
+ addr => addr.guid != prefilledGuids.address1GUID
+ );
+ let tempAddressGuid = tempAddressOption.guid;
+ // select the new address
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.selectShippingAddressByGuid,
+ tempAddressGuid
+ );
+
+ info("awaiting the shippingaddresschange event");
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ async args => {
+ let { address, tempAddressGuid, prefilledGuids: guids } = args;
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.selectedShippingAddress == tempAddressGuid;
+ },
+ "Check the temp address is the selectedShippingAddress"
+ );
+
+ let addressGUIDs = Object.keys(state.tempAddresses);
+ is(addressGUIDs.length, 1, "Check there is one address");
+
+ is(
+ addressGUIDs[0],
+ tempAddressGuid,
+ "guid from state and picker options match"
+ );
+ let tempAddress = state.tempAddresses[tempAddressGuid];
+ for (let [key, val] of Object.entries(address)) {
+ is(tempAddress[key], val, "Check field " + key);
+ }
+ ok(tempAddress.guid, "Address has a guid");
+ ok(tempAddress.name, "Address has a name");
+ ok(
+ tempAddress.name.includes(address["given-name"]) &&
+ tempAddress.name.includes(address["family-name"]),
+ "Address.name was computed"
+ );
+
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ guids.card1GUID
+ );
+ },
+ { address: addressToAdd, tempAddressGuid, prefilledGuids }
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "123",
+ }
+ );
+
+ info("clicking pay");
+ await loginAndCompletePayment(frame);
+
+ // Add a handler to complete the payment above.
+ info("acknowledging the completion from the merchant page");
+ let result = await SpecialPowers.spawn(
+ browser,
+ [],
+ PTU.ContentTasks.addCompletionHandler
+ );
+
+ // Verify response has the expected properties
+ info("response: " + JSON.stringify(result.response));
+ let responseAddress = result.response.shippingAddress;
+ ok(responseAddress, "response should contain the shippingAddress");
+ ok(
+ responseAddress.recipient.includes(addressToAdd["given-name"]),
+ "Check given-name matches recipient in response"
+ );
+ ok(
+ responseAddress.recipient.includes(addressToAdd["family-name"]),
+ "Check family-name matches recipient in response"
+ );
+ is(
+ responseAddress.addressLine[0],
+ addressToAdd["street-address"],
+ "Check street-address in response"
+ );
+ is(
+ responseAddress.country,
+ addressToAdd.country,
+ "Check country in response"
+ );
+ }
+ );
+ // verify formautofill store doesnt have the new temp addresses
+ is(
+ (await formAutofillStorage.addresses.getAll()).length,
+ 1,
+ "Original 1 stored address at end of test"
+ );
+});
+
+add_task(async function test_countrySpecificFieldsGetRequiredness() {
+ await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ let addOptions = {
+ addLinkSelector: "address-picker.shipping-related .add-link",
+ checkboxSelector: "#shipping-address-page .persist-checkbox",
+ initialPageId: "payment-summary",
+ addressPageId: "shipping-address-page",
+ expectPersist: true,
+ };
+
+ await navigateToAddAddressPage(frame, addOptions);
+
+ const EXPECTED_ADDRESS = {
+ country: "MO",
+ "given-name": "First",
+ "family-name": "Last",
+ "street-address": "12345 FooFoo Bar",
+ };
+ await fillInShippingAddressForm(frame, EXPECTED_ADDRESS, addOptions);
+ await submitAddressForm(frame, EXPECTED_ADDRESS, addOptions);
+
+ await navigateToAddAddressPage(frame, addOptions);
+
+ await selectPaymentDialogShippingAddressByCountry(frame, "MO");
+
+ await spawnPaymentDialogTask(frame, async () => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let editLink = content.document.querySelector(
+ "address-picker.shipping-related .edit-link"
+ );
+ is(editLink.textContent, "Edit", "Edit link text");
+
+ editLink.click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "shipping-address-page" &&
+ !!state["shipping-address-page"].guid
+ );
+ },
+ "Check edit page state"
+ );
+
+ let addressForm = content.document.getElementById(
+ "shipping-address-page"
+ );
+ let provinceField = addressForm.querySelector("#address-level1");
+ let provinceContainer = provinceField.parentNode;
+ is(
+ provinceContainer.style.display,
+ "none",
+ "Province should be hidden for Macau"
+ );
+
+ let countryField = addressForm.querySelector("#country");
+ await content.fillField(countryField, "CA");
+ info("changed selected country to Canada");
+
+ isnot(
+ provinceContainer.style.display,
+ "none",
+ "Province should be visible for Canada"
+ );
+ ok(
+ provinceContainer.hasAttribute("required"),
+ "Province container should have required attribute"
+ );
+ let provinceSpan = provinceContainer.querySelector("span");
+ is(
+ provinceSpan.getAttribute("fieldRequiredSymbol"),
+ "*",
+ "Province span should have asterisk as fieldRequiredSymbol"
+ );
+ is(
+ content.window.getComputedStyle(provinceSpan, "::after").content,
+ "attr(fieldRequiredSymbol)",
+ "Asterisk should be on Province"
+ );
+
+ let addressBackButton = addressForm.querySelector(".back-button");
+ addressBackButton.click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "Switched back to payment-summary"
+ );
+ });
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ await cleanupFormAutofillStorage();
+});
diff --git a/browser/components/payments/test/browser/browser_address_edit_hidden_fields.js b/browser/components/payments/test/browser/browser_address_edit_hidden_fields.js
new file mode 100644
index 0000000000..859bde1b10
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_address_edit_hidden_fields.js
@@ -0,0 +1,477 @@
+/**
+ * Test saving/updating address records with fields sometimes not visible to the user.
+ */
+
+"use strict";
+
+async function setup() {
+ await setupFormAutofillStorage();
+ await cleanupFormAutofillStorage();
+ // add an address and card to avoid the FTU sequence
+ let prefilledGuids = await addSampleAddressesAndBasicCard(
+ [PTU.Addresses.TimBL],
+ [PTU.BasicCards.JohnDoe]
+ );
+
+ info("associating the card with the billing address");
+ await formAutofillStorage.creditCards.update(
+ prefilledGuids.card1GUID,
+ {
+ billingAddressGUID: prefilledGuids.address1GUID,
+ },
+ true
+ );
+
+ return prefilledGuids;
+}
+
+add_task(async function test_hiddenFieldNotSaved() {
+ await setup();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ let newAddress = Object.assign({}, PTU.Addresses.TimBL);
+ newAddress["given-name"] = "hiddenFields";
+
+ let shippingAddressChangePromise = SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ let options = {
+ addLinkSelector: "address-picker.shipping-related .add-link",
+ initialPageId: "payment-summary",
+ addressPageId: "shipping-address-page",
+ expectPersist: true,
+ isEditing: false,
+ };
+ await navigateToAddAddressPage(frame, options);
+ info("navigated to add address page");
+ await fillInShippingAddressForm(frame, newAddress, options);
+ info("filled in TimBL address");
+
+ await spawnPaymentDialogTask(frame, async args => {
+ let addressForm = content.document.getElementById(
+ "shipping-address-page"
+ );
+ let countryField = addressForm.querySelector("#country");
+ await content.fillField(countryField, "DE");
+ });
+ info("changed selected country to Germany");
+
+ await submitAddressForm(frame, null, options);
+ await shippingAddressChangePromise;
+ info("got shippingaddresschange event");
+
+ await spawnPaymentDialogTask(frame, async () => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let { savedAddresses } = await PTU.DialogContentUtils.getCurrentState(
+ content
+ );
+ is(Object.keys(savedAddresses).length, 2, "2 saved addresses");
+ let savedAddress = Object.values(savedAddresses).find(
+ address => address["given-name"] == "hiddenFields"
+ );
+ ok(savedAddress, "found the saved address");
+ is(savedAddress.country, "DE", "check country");
+ is(
+ savedAddress["address-level2"],
+ PTU.Addresses.TimBL["address-level2"],
+ "check address-level2"
+ );
+ is(
+ savedAddress["address-level1"],
+ undefined,
+ "address-level1 should not be saved"
+ );
+ });
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ await cleanupFormAutofillStorage();
+});
+
+add_task(async function test_hiddenFieldRemovedWhenCountryChanged() {
+ await setup();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ let shippingAddressChangePromise = SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ await spawnPaymentDialogTask(frame, async args => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let picker = content.document.querySelector(
+ "address-picker[selected-state-key='selectedShippingAddress']"
+ );
+ Cu.waiveXrays(picker).dropdown.popupBox.focus();
+ EventUtils.synthesizeKey(
+ PTU.Addresses.TimBL["given-name"],
+ {},
+ content.window
+ );
+
+ let editLink = content.document.querySelector(
+ "address-picker .edit-link"
+ );
+ is(editLink.textContent, "Edit", "Edit link text");
+
+ editLink.click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "shipping-address-page" &&
+ !!state["shipping-address-page"].guid
+ );
+ },
+ "Check edit page state"
+ );
+
+ let addressForm = content.document.getElementById(
+ "shipping-address-page"
+ );
+ let countryField = addressForm.querySelector("#country");
+ await content.fillField(countryField, "DE");
+ info("changed selected country to Germany");
+ });
+
+ let options = {
+ isEditing: true,
+ addressPageId: "shipping-address-page",
+ };
+ await submitAddressForm(frame, null, options);
+ await shippingAddressChangePromise;
+ info("got shippingaddresschange event");
+
+ await spawnPaymentDialogTask(frame, async () => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let { savedAddresses } = await PTU.DialogContentUtils.getCurrentState(
+ content
+ );
+ is(Object.keys(savedAddresses).length, 1, "1 saved address");
+ let savedAddress = Object.values(savedAddresses)[0];
+ is(savedAddress.country, "DE", "check country");
+ is(
+ savedAddress["address-level2"],
+ PTU.Addresses.TimBL["address-level2"],
+ "check address-level2"
+ );
+ is(
+ savedAddress["address-level1"],
+ undefined,
+ "address-level1 should not be saved"
+ );
+ });
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ await cleanupFormAutofillStorage();
+});
+
+add_task(async function test_hiddenNonShippingFieldsPreservedUponEdit() {
+ await setupFormAutofillStorage();
+ await cleanupFormAutofillStorage();
+ // add a Brazilian address (since it uses all fields) and card to avoid the FTU sequence
+ let prefilledGuids = await addSampleAddressesAndBasicCard(
+ [PTU.Addresses.TimBR],
+ [PTU.BasicCards.JohnDoe]
+ );
+ let expected = await formAutofillStorage.addresses.get(
+ prefilledGuids.address1GUID
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await selectPaymentDialogShippingAddressByCountry(frame, "BR");
+
+ await navigateToAddShippingAddressPage(frame, {
+ addLinkSelector:
+ 'address-picker[selected-state-key="selectedShippingAddress"] .edit-link',
+ addressPageId: "shipping-address-page",
+ initialPageId: "payment-summary",
+ });
+
+ await spawnPaymentDialogTask(frame, async () => {
+ let givenNameField = content.document.querySelector(
+ "#shipping-address-page #given-name"
+ );
+ await content.fillField(givenNameField, "Timothy-edit");
+ });
+
+ let options = {
+ isEditing: true,
+ };
+ await submitAddressForm(frame, null, options);
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+
+ Object.assign(expected, PTU.Addresses.TimBR, {
+ "given-name": "Timothy-edit",
+ name: "Timothy-edit Jo\u{00E3}o Berners-Lee",
+ });
+ let actual = await formAutofillStorage.addresses.get(
+ prefilledGuids.address1GUID
+ );
+ isnot(actual.email, "", "Check email isn't empty");
+ // timeLastModified changes and isn't relevant
+ delete actual.timeLastModified;
+ delete expected.timeLastModified;
+ SimpleTest.isDeeply(actual, expected, "Check profile didn't lose fields");
+
+ await cleanupFormAutofillStorage();
+});
+
+add_task(async function test_hiddenNonPayerFieldsPreservedUponEdit() {
+ await setupFormAutofillStorage();
+ await cleanupFormAutofillStorage();
+ // add a Brazilian address (since it uses all fields) and card to avoid the FTU sequence
+ let prefilledGuids = await addSampleAddressesAndBasicCard(
+ [PTU.Addresses.TimBR],
+ [PTU.BasicCards.JohnDoe]
+ );
+ let expected = await formAutofillStorage.addresses.get(
+ prefilledGuids.address1GUID
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign({}, PTU.Details.total2USD),
+ options: {
+ requestPayerEmail: true,
+ },
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await navigateToAddAddressPage(frame, {
+ addLinkSelector:
+ 'address-picker[selected-state-key="selectedPayerAddress"] .edit-link',
+ initialPageId: "payment-summary",
+ addressPageId: "payer-address-page",
+ });
+
+ info("Change the email address");
+ await spawnPaymentDialogTask(
+ frame,
+ `async () => {
+ let emailField = content.document.querySelector("#payer-address-page #email");
+ await content.fillField(emailField, "new@example.com");
+ }`
+ );
+
+ let options = {
+ isEditing: true,
+ };
+ await submitAddressForm(frame, null, options);
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+
+ Object.assign(expected, PTU.Addresses.TimBR, {
+ email: "new@example.com",
+ });
+ let actual = await formAutofillStorage.addresses.get(
+ prefilledGuids.address1GUID
+ );
+ // timeLastModified changes and isn't relevant
+ delete actual.timeLastModified;
+ delete expected.timeLastModified;
+ SimpleTest.isDeeply(
+ actual,
+ expected,
+ "Check profile didn't lose fields and change was made"
+ );
+
+ await cleanupFormAutofillStorage();
+});
+
+add_task(async function test_hiddenNonBillingAddressFieldsPreservedUponEdit() {
+ await setupFormAutofillStorage();
+ await cleanupFormAutofillStorage();
+ // add a Brazilian address (since it uses all fields) and card to avoid the FTU sequence
+ let prefilledGuids = await addSampleAddressesAndBasicCard(
+ [PTU.Addresses.TimBR],
+ [PTU.BasicCards.JohnDoe]
+ );
+ let expected = await formAutofillStorage.addresses.get(
+ prefilledGuids.address1GUID
+ );
+
+ info("associating the card with the billing address");
+ await formAutofillStorage.creditCards.update(
+ prefilledGuids.card1GUID,
+ {
+ billingAddressGUID: prefilledGuids.address1GUID,
+ },
+ true
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await navigateToAddCardPage(frame, {
+ addLinkSelector: "payment-method-picker .edit-link",
+ });
+
+ await navigateToAddAddressPage(frame, {
+ addLinkSelector: ".billingAddressRow .edit-link",
+ initialPageId: "basic-card-page",
+ addressPageId: "billing-address-page",
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ `async () => {
+ let givenNameField = content.document.querySelector(
+ "#billing-address-page #given-name"
+ );
+ await content.fillField(givenNameField, "Timothy-edit");
+ }`
+ );
+
+ let options = {
+ isEditing: true,
+ nextPageId: "basic-card-page",
+ };
+ await submitAddressForm(frame, null, options);
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+
+ Object.assign(expected, PTU.Addresses.TimBR, {
+ "given-name": "Timothy-edit",
+ name: "Timothy-edit Jo\u{00E3}o Berners-Lee",
+ });
+ let actual = await formAutofillStorage.addresses.get(
+ prefilledGuids.address1GUID
+ );
+ // timeLastModified changes and isn't relevant
+ delete actual.timeLastModified;
+ delete expected.timeLastModified;
+ SimpleTest.isDeeply(actual, expected, "Check profile didn't lose fields");
+
+ await cleanupFormAutofillStorage();
+});
diff --git a/browser/components/payments/test/browser/browser_card_edit.js b/browser/components/payments/test/browser/browser_card_edit.js
new file mode 100644
index 0000000000..9ffc6c22ff
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_card_edit.js
@@ -0,0 +1,1227 @@
+/* eslint-disable no-shadow */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+async function setup(addresses = [], cards = []) {
+ await setupFormAutofillStorage();
+ await cleanupFormAutofillStorage();
+ let prefilledGuids = await addSampleAddressesAndBasicCard(addresses, cards);
+ return prefilledGuids;
+}
+
+async function add_link(aOptions = {}) {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let tabOpenFn = aOptions.isPrivate
+ ? withNewTabInPrivateWindow
+ : BrowserTestUtils.withNewTab;
+ await tabOpenFn(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign({}, PTU.Details.total60USD),
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+ info("add_link, aOptions: " + JSON.stringify(aOptions, null, 2));
+ await navigateToAddCardPage(frame);
+ info(`add_link, from the add card page,
+ verifyPersistCheckbox with expectPersist: ${aOptions.expectDefaultCardPersist}`);
+ await verifyPersistCheckbox(frame, {
+ checkboxSelector: "basic-card-form .persist-checkbox",
+ expectPersist: aOptions.expectDefaultCardPersist,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkState(testArgs = {}) {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ Object.keys(state.savedBasicCards).length == 1 &&
+ Object.keys(state.savedAddresses).length == 1
+ );
+ },
+ "Check no cards or addresses present at beginning of test"
+ );
+
+ let title = content.document.querySelector("basic-card-form h2");
+ is(title.textContent, "Add Credit Card", "Add title should be set");
+
+ let saveButton = content.document.querySelector(
+ "basic-card-form .save-button"
+ );
+ is(
+ saveButton.textContent,
+ "Next",
+ "Save button has the correct label"
+ );
+
+ is(
+ state.isPrivate,
+ testArgs.isPrivate,
+ "isPrivate flag has expected value when shown from a private/non-private session"
+ );
+ },
+ aOptions
+ );
+
+ let cardOptions = Object.assign(
+ {},
+ {
+ checkboxSelector: "basic-card-form .persist-checkbox",
+ expectPersist: aOptions.expectCardPersist,
+ networkSelector: "basic-card-form #cc-type",
+ expectedNetwork: PTU.BasicCards.JaneMasterCard["cc-type"],
+ }
+ );
+ if (aOptions.hasOwnProperty("setCardPersistCheckedValue")) {
+ cardOptions.setPersistCheckedValue =
+ aOptions.setCardPersistCheckedValue;
+ }
+ await fillInCardForm(
+ frame,
+ {
+ ["cc-csc"]: 123,
+ ...PTU.BasicCards.JaneMasterCard,
+ },
+ cardOptions
+ );
+
+ await verifyCardNetwork(frame, cardOptions);
+ await verifyPersistCheckbox(frame, cardOptions);
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkBillingAddressPicker(testArgs = {}) {
+ let billingAddressSelect = content.document.querySelector(
+ "#billingAddressGUID"
+ );
+ ok(
+ content.isVisible(billingAddressSelect),
+ "The billing address selector should always be visible"
+ );
+ is(
+ billingAddressSelect.childElementCount,
+ 2,
+ "Only 2 child options should exist by default"
+ );
+ is(
+ billingAddressSelect.children[0].value,
+ "",
+ "The first option should be the blank/empty option"
+ );
+ ok(
+ billingAddressSelect.children[1].value,
+ "The 2nd option is the prefilled address and should be truthy"
+ );
+ },
+ aOptions
+ );
+
+ let addressOptions = Object.assign({}, aOptions, {
+ addLinkSelector: ".billingAddressRow .add-link",
+ checkboxSelector: "#billing-address-page .persist-checkbox",
+ initialPageId: "basic-card-page",
+ addressPageId: "billing-address-page",
+ expectPersist: aOptions.expectDefaultAddressPersist,
+ });
+ if (aOptions.hasOwnProperty("setAddressPersistCheckedValue")) {
+ addressOptions.setPersistCheckedValue =
+ aOptions.setAddressPersistCheckedValue;
+ }
+
+ await navigateToAddAddressPage(frame, addressOptions);
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkTask(testArgs = {}) {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let title = content.document.querySelector("basic-card-form h2");
+ let card = Object.assign({}, PTU.BasicCards.JaneMasterCard);
+
+ let addressForm = content.document.querySelector(
+ "#billing-address-page"
+ );
+ ok(content.isVisible(addressForm), "Billing address page is visible");
+
+ let addressTitle = addressForm.querySelector("h2");
+ is(
+ addressTitle.textContent,
+ "Add Billing Address",
+ "Address on add address page should be correct"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ let total =
+ Object.keys(state.savedBasicCards).length +
+ Object.keys(state.tempBasicCards).length;
+ return total == 1;
+ },
+ "Check card was not added when clicking the 'add' address button"
+ );
+
+ let addressBackButton = addressForm.querySelector(".back-button");
+ addressBackButton.click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ let total =
+ Object.keys(state.savedBasicCards).length +
+ Object.keys(state.tempBasicCards).length;
+ return (
+ state.page.id == "basic-card-page" &&
+ !state["basic-card-page"].guid &&
+ total == 1
+ );
+ },
+ "Check basic-card page, but card should not be saved and no new addresses present"
+ );
+
+ is(
+ title.textContent,
+ "Add Credit Card",
+ "Add title should be still be on credit card page"
+ );
+
+ for (let [key, val] of Object.entries(card)) {
+ let field = content.document.getElementById(key);
+ is(
+ field.value,
+ val,
+ "Field should still have previous value entered"
+ );
+ ok(!field.disabled, "Fields should still be enabled for editing");
+ }
+ },
+ aOptions
+ );
+
+ await navigateToAddAddressPage(frame, addressOptions);
+
+ await fillInBillingAddressForm(
+ frame,
+ PTU.Addresses.TimBL2,
+ addressOptions
+ );
+
+ await verifyPersistCheckbox(frame, addressOptions);
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkCardPage(testArgs = {}) {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "basic-card-page" &&
+ !state["basic-card-page"].guid
+ );
+ },
+ "Check address was added and we're back on basic-card page (add)"
+ );
+
+ let addressCount =
+ Object.keys(state.savedAddresses).length +
+ Object.keys(state.tempAddresses).length;
+ is(addressCount, 2, "Check address was added");
+
+ let addressColn = testArgs.expectAddressPersist
+ ? state.savedAddresses
+ : state.tempAddresses;
+
+ ok(
+ state["basic-card-page"].preserveFieldValues,
+ "preserveFieldValues should be set when coming back from address-page"
+ );
+
+ ok(
+ state["basic-card-page"].billingAddressGUID,
+ "billingAddressGUID should be set when coming back from address-page"
+ );
+
+ let billingAddressPicker = Cu.waiveXrays(
+ content.document.querySelector(
+ "basic-card-form billing-address-picker"
+ )
+ );
+
+ is(
+ billingAddressPicker.options.length,
+ 3,
+ "Three options should exist in the billingAddressPicker"
+ );
+ let selectedOption = billingAddressPicker.dropdown.selectedOption;
+ let selectedAddressGuid = selectedOption.value;
+ let lastAddress = Object.values(addressColn)[
+ Object.keys(addressColn).length - 1
+ ];
+ is(
+ selectedAddressGuid,
+ lastAddress.guid,
+ "The select should have the new address selected"
+ );
+ },
+ aOptions
+ );
+
+ cardOptions = Object.assign(
+ {},
+ {
+ checkboxSelector: "basic-card-form .persist-checkbox",
+ expectPersist: aOptions.expectCardPersist,
+ }
+ );
+
+ await verifyPersistCheckbox(frame, cardOptions);
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkSaveButtonUpdatesOnCCNumberChange() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let button = content.document.querySelector(
+ `basic-card-form button.primary`
+ );
+ ok(!button.disabled, "Save button should not be disabled");
+
+ let field = content.document.getElementById("cc-number");
+ field.focus();
+ EventUtils.sendString("a", content.window);
+ button.focus();
+
+ ok(
+ button.disabled,
+ "Save button should be disabled with incorrect number"
+ );
+
+ field.focus();
+ content.fillField(field, PTU.BasicCards.JaneMasterCard["cc-number"]);
+ button.focus();
+
+ ok(
+ !button.disabled,
+ "Save button should be enabled with correct number"
+ );
+
+ field = content.document.getElementById("cc-csc");
+ field.focus();
+ content.fillField(field, "123");
+ button.focus();
+
+ ok(!button.disabled, "Save button should be enabled with valid CSC");
+ }
+ );
+
+ SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "paymentmethodchange",
+ },
+ ],
+ PTU.ContentTasks.promisePaymentRequestEvent
+ );
+ info("added paymentmethodchange handler");
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ info("waiting for paymentmethodchange event");
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "paymentmethodchange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ await spawnPaymentDialogTask(frame, async function waitForSummaryPage(
+ testArgs = {}
+ ) {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "Check we are back on the summary page"
+ );
+
+ let picker = Cu.waiveXrays(
+ content.document.querySelector("payment-method-picker")
+ );
+ is(
+ picker.securityCodeInput.querySelector("input").value,
+ "123",
+ "Security code should be populated using the value set from the 'add' page"
+ );
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkCardState(testArgs = {}) {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let { prefilledGuids } = testArgs;
+ let card = Object.assign({}, PTU.BasicCards.JaneMasterCard);
+ let state = await PTU.DialogContentUtils.getCurrentState(content);
+
+ let cardCount =
+ Object.keys(state.savedBasicCards).length +
+ Object.keys(state.tempBasicCards).length;
+ is(cardCount, 2, "Card was added");
+ if (testArgs.expectCardPersist) {
+ is(
+ Object.keys(state.tempBasicCards).length,
+ 0,
+ "No temporary cards addded"
+ );
+ is(
+ Object.keys(state.savedBasicCards).length,
+ 2,
+ "New card was saved"
+ );
+ } else {
+ is(
+ Object.keys(state.tempBasicCards).length,
+ 1,
+ "Card was added temporarily"
+ );
+ is(
+ Object.keys(state.savedBasicCards).length,
+ 1,
+ "No change to saved cards"
+ );
+ }
+
+ let cardCollection = testArgs.expectCardPersist
+ ? state.savedBasicCards
+ : state.tempBasicCards;
+ let addressCollection = testArgs.expectAddressPersist
+ ? state.savedAddresses
+ : state.tempAddresses;
+ let savedCardGUID = Object.keys(cardCollection).find(
+ key => key != prefilledGuids.card1GUID
+ );
+ let savedAddressGUID = Object.keys(addressCollection).find(
+ key => key != prefilledGuids.address1GUID
+ );
+ let savedCard = savedCardGUID && cardCollection[savedCardGUID];
+
+ // we should never have an un-masked cc-number in the state:
+ ok(
+ Object.values(cardCollection).every(card =>
+ card["cc-number"].startsWith("************")
+ ),
+ "All cc-numbers in state are masked"
+ );
+ card["cc-number"] = "************4444"; // Expect card number to be masked at this point
+ for (let [key, val] of Object.entries(card)) {
+ is(savedCard[key], val, "Check " + key);
+ }
+
+ is(
+ savedCard.billingAddressGUID,
+ savedAddressGUID,
+ "The saved card should be associated with the billing address"
+ );
+ },
+ aOptions
+ );
+
+ await loginAndCompletePayment(frame);
+
+ // Add a handler to complete the payment above.
+ info("acknowledging the completion from the merchant page");
+ let result = await SpecialPowers.spawn(
+ browser,
+ [],
+ PTU.ContentTasks.addCompletionHandler
+ );
+
+ // Verify response has the expected properties
+ let expectedDetails = Object.assign(
+ {
+ "cc-security-code": "123",
+ },
+ PTU.BasicCards.JaneMasterCard
+ );
+ let expectedBillingAddress = PTU.Addresses.TimBL2;
+ let cardDetails = result.response.details;
+
+ checkPaymentMethodDetailsMatchesCard(
+ cardDetails,
+ expectedDetails,
+ "Check response payment details"
+ );
+ checkPaymentAddressMatchesStorageAddress(
+ cardDetails.billingAddress,
+ expectedBillingAddress,
+ "Check response billing address"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+}
+
+add_task(async function test_add_link() {
+ let prefilledGuids = await setup(
+ [PTU.Addresses.TimBL],
+ [PTU.BasicCards.JohnDoe]
+ );
+ let defaultPersist = Services.prefs.getBoolPref(SAVE_CREDITCARD_DEFAULT_PREF);
+
+ is(
+ defaultPersist,
+ false,
+ `Expect ${SAVE_CREDITCARD_DEFAULT_PREF} to default to false`
+ );
+ info("Calling add_link from test_add_link");
+ await add_link({
+ isPrivate: false,
+ expectDefaultCardPersist: false,
+ expectCardPersist: false,
+ expectDefaultAddressPersist: true,
+ expectAddressPersist: true,
+ prefilledGuids,
+ });
+});
+
+add_task(async function test_private_add_link() {
+ let prefilledGuids = await setup(
+ [PTU.Addresses.TimBL],
+ [PTU.BasicCards.JohnDoe]
+ );
+ info("Calling add_link from test_private_add_link");
+ await add_link({
+ isPrivate: true,
+ expectDefaultCardPersist: false,
+ expectCardPersist: false,
+ expectDefaultAddressPersist: false,
+ expectAddressPersist: false,
+ prefilledGuids,
+ });
+});
+
+add_task(async function test_persist_prefd_on_add_link() {
+ let prefilledGuids = await setup(
+ [PTU.Addresses.TimBL],
+ [PTU.BasicCards.JohnDoe]
+ );
+ Services.prefs.setBoolPref(SAVE_CREDITCARD_DEFAULT_PREF, true);
+
+ info("Calling add_link from test_persist_prefd_on_add_link");
+ await add_link({
+ isPrivate: false,
+ expectDefaultCardPersist: true,
+ expectCardPersist: true,
+ expectDefaultAddressPersist: true,
+ expectAddressPersist: true,
+ prefilledGuids,
+ });
+ Services.prefs.clearUserPref(SAVE_CREDITCARD_DEFAULT_PREF);
+});
+
+add_task(async function test_private_persist_prefd_on_add_link() {
+ let prefilledGuids = await setup(
+ [PTU.Addresses.TimBL],
+ [PTU.BasicCards.JohnDoe]
+ );
+ Services.prefs.setBoolPref(SAVE_CREDITCARD_DEFAULT_PREF, true);
+
+ info("Calling add_link from test_private_persist_prefd_on_add_link");
+ // in private window, even when the pref is set true,
+ // we should still default to not saving credit-card info
+ await add_link({
+ isPrivate: true,
+ expectDefaultCardPersist: false,
+ expectCardPersist: false,
+ expectDefaultAddressPersist: false,
+ expectAddressPersist: false,
+ prefilledGuids,
+ });
+ Services.prefs.clearUserPref(SAVE_CREDITCARD_DEFAULT_PREF);
+});
+
+add_task(async function test_optin_persist_add_link() {
+ let prefilledGuids = await setup(
+ [PTU.Addresses.TimBL],
+ [PTU.BasicCards.JohnDoe]
+ );
+ let defaultPersist = Services.prefs.getBoolPref(SAVE_CREDITCARD_DEFAULT_PREF);
+
+ is(
+ defaultPersist,
+ false,
+ `Expect ${SAVE_CREDITCARD_DEFAULT_PREF} to default to false`
+ );
+ info("Calling add_link from test_add_link");
+ // verify that explicit opt-in by checking the box results in the record being saved
+ await add_link({
+ isPrivate: false,
+ expectDefaultCardPersist: false,
+ setCardPersistCheckedValue: true,
+ expectCardPersist: true,
+ expectDefaultAddressPersist: true,
+ expectAddressPersist: true,
+ prefilledGuids,
+ });
+});
+
+add_task(async function test_optin_private_persist_add_link() {
+ let prefilledGuids = await setup(
+ [PTU.Addresses.TimBL],
+ [PTU.BasicCards.JohnDoe]
+ );
+ let defaultPersist = Services.prefs.getBoolPref(SAVE_CREDITCARD_DEFAULT_PREF);
+
+ is(
+ defaultPersist,
+ false,
+ `Expect ${SAVE_CREDITCARD_DEFAULT_PREF} to default to false`
+ );
+ // verify that explicit opt-in for the card only from a private window results
+ // in the record being saved
+ await add_link({
+ isPrivate: true,
+ expectDefaultCardPersist: false,
+ setCardPersistCheckedValue: true,
+ expectCardPersist: true,
+ expectDefaultAddressPersist: false,
+ expectAddressPersist: false,
+ prefilledGuids,
+ });
+});
+
+add_task(async function test_opt_out_persist_prefd_on_add_link() {
+ let prefilledGuids = await setup(
+ [PTU.Addresses.TimBL],
+ [PTU.BasicCards.JohnDoe]
+ );
+ Services.prefs.setBoolPref(SAVE_CREDITCARD_DEFAULT_PREF, true);
+
+ // set the pref to default to persist creditcards, but manually uncheck the checkbox
+ await add_link({
+ isPrivate: false,
+ expectDefaultCardPersist: true,
+ setCardPersistCheckedValue: false,
+ expectCardPersist: false,
+ expectDefaultAddressPersist: true,
+ expectAddressPersist: true,
+ prefilledGuids,
+ });
+ Services.prefs.clearUserPref(SAVE_CREDITCARD_DEFAULT_PREF);
+});
+
+add_task(async function test_edit_link() {
+ // add an address and card linked to this address
+ let prefilledGuids = await setup([PTU.Addresses.TimBL, PTU.Addresses.TimBL2]);
+ {
+ let card = Object.assign({}, PTU.BasicCards.JohnDoe, {
+ billingAddressGUID: prefilledGuids.address1GUID,
+ });
+ let guid = await addCardRecord(card);
+ prefilledGuids.card1GUID = guid;
+ }
+
+ const args = {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ prefilledGuids,
+ };
+ await spawnInDialogForMerchantTask(
+ PTU.ContentTasks.createAndShowRequest,
+ async function check({ prefilledGuids }) {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ let editLink = paymentMethodPicker.querySelector(".edit-link");
+
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ prefilledGuids.card1GUID
+ );
+
+ 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["basic-card-page"].guid
+ );
+ },
+ "Check edit page state"
+ );
+
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ Object.keys(state.savedBasicCards).length == 1 &&
+ Object.keys(state.savedAddresses).length == 2
+ );
+ },
+ "Check card and address present at beginning of test"
+ );
+
+ let title = content.document.querySelector("basic-card-form h2");
+ is(title.textContent, "Edit Credit Card", "Edit title should be set");
+
+ let saveButton = content.document.querySelector(
+ "basic-card-form .save-button"
+ );
+ is(saveButton.textContent, "Update", "Save button has the correct label");
+
+ let card = Object.assign({}, PTU.BasicCards.JohnDoe);
+ // cc-number cannot be modified
+ delete card["cc-number"];
+ card["cc-exp-year"]++;
+ card["cc-exp-month"]++;
+
+ 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"
+ );
+
+ let billingAddressPicker = Cu.waiveXrays(
+ content.document.querySelector("basic-card-form billing-address-picker")
+ );
+
+ let initialSelectedAddressGuid = billingAddressPicker.dropdown.value;
+ is(
+ billingAddressPicker.options.length,
+ 3,
+ "Three options should exist in the billingAddressPicker"
+ );
+ is(
+ initialSelectedAddressGuid,
+ prefilledGuids.address1GUID,
+ "The prefilled billing address should be selected by default"
+ );
+
+ info("Test clicking 'add' with the empty option first");
+ billingAddressPicker.dropdown.popupBox.focus();
+ content.fillField(billingAddressPicker.dropdown.popupBox, "");
+
+ let addressEditLink = content.document.querySelector(
+ ".billingAddressRow .edit-link"
+ );
+ ok(
+ addressEditLink && !content.isVisible(addressEditLink),
+ "The edit link is hidden when empty option is selected"
+ );
+
+ let addressAddLink = content.document.querySelector(
+ ".billingAddressRow .add-link"
+ );
+ addressAddLink.click();
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "billing-address-page" &&
+ !state["billing-address-page"].guid
+ );
+ },
+ "Clicking add button when the empty option is selected will go to 'add' page (no guid)"
+ );
+
+ let addressForm = content.document.querySelector("#billing-address-page");
+ ok(content.isVisible(addressForm), "Billing address form is showing");
+
+ let addressTitle = addressForm.querySelector("h2");
+ is(
+ addressTitle.textContent,
+ "Add Billing Address",
+ "Title on add address page should be correct"
+ );
+
+ let addressBackButton = addressForm.querySelector(".back-button");
+ addressBackButton.click();
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "basic-card-page" &&
+ state["basic-card-page"].guid &&
+ Object.keys(state.savedAddresses).length == 2
+ );
+ },
+ "Check we're back at basic-card page with no state changed after adding"
+ );
+
+ info(
+ "Inspect a different address and ensure it remains selected when we go back"
+ );
+ content.fillField(
+ billingAddressPicker.dropdown.popupBox,
+ prefilledGuids.address2GUID
+ );
+
+ addressEditLink.click();
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "billing-address-page" &&
+ state["billing-address-page"].guid
+ );
+ },
+ "Clicking edit button with selected option will go to 'edit' page"
+ );
+
+ let countryPicker = addressForm.querySelector("#country");
+ is(
+ countryPicker.value,
+ PTU.Addresses.TimBL2.country,
+ "The country value matches"
+ );
+
+ addressBackButton.click();
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "basic-card-page" &&
+ state["basic-card-page"].guid &&
+ Object.keys(state.savedAddresses).length == 2
+ );
+ },
+ "Check we're back at basic-card page with no state changed after editing"
+ );
+
+ is(
+ billingAddressPicker.dropdown.value,
+ prefilledGuids.address2GUID,
+ "The selected billing address is correct"
+ );
+
+ info("Go back to previously selected option before clicking 'edit' now");
+ content.fillField(
+ billingAddressPicker.dropdown.popupBox,
+ initialSelectedAddressGuid
+ );
+
+ let selectedOption = billingAddressPicker.dropdown.selectedOption;
+ ok(
+ selectedOption && selectedOption.value,
+ "select should have a selected option value"
+ );
+
+ addressEditLink.click();
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "billing-address-page" &&
+ state["billing-address-page"].guid
+ );
+ },
+ "Check address page state (editing)"
+ );
+
+ is(
+ addressTitle.textContent,
+ "Edit Billing Address",
+ "Address on edit address page should be correct"
+ );
+
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return Object.keys(state.savedBasicCards).length == 1;
+ },
+ "Check card was not added again when clicking the 'edit' address button"
+ );
+
+ addressBackButton.click();
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "basic-card-page" &&
+ state["basic-card-page"].guid &&
+ Object.keys(state.savedAddresses).length == 2
+ );
+ },
+ "Check we're back at basic-card page with no state changed after editing"
+ );
+
+ for (let [key, val] of Object.entries(card)) {
+ let field = content.document.getElementById(key);
+ is(field.value, val, "Field should still have previous value entered");
+ }
+
+ selectedOption = billingAddressPicker.dropdown.selectedOption;
+ ok(
+ selectedOption && selectedOption.value,
+ "select should have a selected option value"
+ );
+
+ addressEditLink.click();
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "billing-address-page" &&
+ state["billing-address-page"].guid
+ );
+ },
+ "Check address page state (editing)"
+ );
+
+ info("modify some address fields");
+ for (let key of ["given-name", "tel", "organization", "street-address"]) {
+ let field = addressForm.querySelector(`#${key}`);
+ if (!field) {
+ ok(false, `${key} field not found`);
+ }
+ field.focus();
+ EventUtils.sendKey("BACK_SPACE", content.window);
+ EventUtils.sendString("7", content.window);
+ ok(!field.disabled, `Field #${key} shouldn't be disabled`);
+ }
+
+ addressForm.querySelector("button.save-button").click();
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "basic-card-page" &&
+ state["basic-card-page"].guid &&
+ Object.keys(state.savedAddresses).length == 2
+ );
+ },
+ "Check still only 2 addresses and we're back on basic-card page"
+ );
+
+ is(
+ Object.values(state.savedAddresses)[0].tel,
+ PTU.Addresses.TimBL.tel.slice(0, -1) + "7",
+ "Check that address was edited and saved"
+ );
+
+ content.document
+ .querySelector("basic-card-form button.save-button")
+ .click();
+
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ let cards = Object.entries(state.savedBasicCards);
+ return cards.length == 1 && cards[0][1]["cc-name"] == card["cc-name"];
+ },
+ "Check card was edited"
+ );
+
+ let cardGUIDs = Object.keys(state.savedBasicCards);
+ 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
+ );
+});
+
+add_task(async function test_invalid_network_card_edit() {
+ // add an address and card linked to this address
+ let prefilledGuids = await setup([PTU.Addresses.TimBL]);
+ {
+ let card = Object.assign({}, PTU.BasicCards.JohnDoe, {
+ billingAddressGUID: prefilledGuids.address1GUID,
+ });
+ // create a record with an unknown network id
+ card["cc-type"] = "asiv";
+ let guid = await addCardRecord(card);
+ prefilledGuids.card1GUID = guid;
+ }
+
+ const args = {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ prefilledGuids,
+ };
+ await spawnInDialogForMerchantTask(
+ PTU.ContentTasks.createAndShowRequest,
+ async function check({ prefilledGuids }) {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ let editLink = paymentMethodPicker.querySelector(".edit-link");
+
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ prefilledGuids.card1GUID
+ );
+
+ 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["basic-card-page"].guid
+ );
+ },
+ "Check edit page state"
+ );
+
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ Object.keys(state.savedBasicCards).length == 1 &&
+ Object.keys(state.savedAddresses).length == 1
+ );
+ },
+ "Check card and address present at beginning of test"
+ );
+
+ let networkSelector = content.document.querySelector(
+ "basic-card-form #cc-type"
+ );
+ is(
+ Cu.waiveXrays(networkSelector).selectedIndex,
+ -1,
+ "An invalid cc-type should result in no selection"
+ );
+ is(
+ Cu.waiveXrays(networkSelector).value,
+ "",
+ "An invalid cc-type should result in an empty string as value"
+ );
+
+ ok(
+ content.document.querySelector("basic-card-form button.save-button")
+ .disabled,
+ "Save button should be disabled due to a missing cc-type"
+ );
+
+ content.fillField(Cu.waiveXrays(networkSelector), "visa");
+
+ ok(
+ !content.document.querySelector("basic-card-form button.save-button")
+ .disabled,
+ "Save button should be enabled after fixing cc-type"
+ );
+ content.document
+ .querySelector("basic-card-form button.save-button")
+ .click();
+
+ // We expect that saving a card with a fixed network will result in the
+ // cc-type property being changed to the new value.
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ let cards = Object.entries(state.savedBasicCards);
+ return cards.length == 1 && cards[0][1]["cc-type"] == "visa";
+ },
+ "Check card was edited"
+ );
+
+ 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-type"],
+ "visa",
+ "We expect the cc-type value to be updated"
+ );
+
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "Switched back to payment-summary"
+ );
+ },
+ args
+ );
+});
+
+add_task(async function test_private_card_adding() {
+ await setup([PTU.Addresses.TimBL], [PTU.BasicCards.JohnDoe]);
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser: privateWin.gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(frame, async function check() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let addLink = content.document.querySelector(
+ "payment-method-picker .add-link"
+ );
+ is(addLink.textContent, "Add", "Add link text");
+
+ addLink.click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "basic-card-page" &&
+ !state["basic-card-page"].guid
+ );
+ },
+ "Check card page state"
+ );
+ });
+
+ await fillInCardForm(frame, {
+ ["cc-csc"]: "999",
+ ...PTU.BasicCards.JohnDoe,
+ });
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let card = Object.assign({}, PTU.BasicCards.JohnDoe);
+ let state = await PTU.DialogContentUtils.getCurrentState(content);
+ let savedCardCount = Object.keys(state.savedBasicCards).length;
+ let tempCardCount = Object.keys(state.tempBasicCards).length;
+ content.document
+ .querySelector("basic-card-form button.save-button")
+ .click();
+
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return Object.keys(state.tempBasicCards).length > tempCardCount;
+ },
+ "Check card was added to temp collection"
+ );
+
+ is(
+ savedCardCount,
+ Object.keys(state.savedBasicCards).length,
+ "No card was saved in state"
+ );
+ is(
+ Object.keys(state.tempBasicCards).length,
+ 1,
+ "Card was added temporarily"
+ );
+
+ let cardGUIDs = Object.keys(state.tempBasicCards);
+ is(cardGUIDs.length, 1, "Check there is one card");
+
+ let tempCard = state.tempBasicCards[cardGUIDs[0]];
+ // Card number should be masked, so skip cc-number in the compare loop below
+ delete card["cc-number"];
+ for (let [key, val] of Object.entries(card)) {
+ is(
+ tempCard[key],
+ val,
+ "Check " + key + ` ${tempCard[key]} matches ${val}`
+ );
+ }
+ // check computed fields
+ is(tempCard["cc-number"], "************1111", "cc-number is masked");
+ is(tempCard["cc-given-name"], "John", "cc-given-name was computed");
+ is(tempCard["cc-family-name"], "Doe", "cc-family-name was computed");
+ ok(tempCard["cc-exp"], "cc-exp was computed");
+ ok(tempCard["cc-number-encrypted"], "cc-number-encrypted was computed");
+ });
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ await BrowserTestUtils.closeWindow(privateWin);
+});
diff --git a/browser/components/payments/test/browser/browser_change_shipping.js b/browser/components/payments/test/browser/browser_change_shipping.js
new file mode 100644
index 0000000000..ec6a389fe7
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_change_shipping.js
@@ -0,0 +1,732 @@
+"use strict";
+
+async function setup() {
+ await setupFormAutofillStorage();
+ let prefilledGuids = await addSampleAddressesAndBasicCard();
+
+ info("associating the card with the billing address");
+ await formAutofillStorage.creditCards.update(
+ prefilledGuids.card1GUID,
+ {
+ billingAddressGUID: prefilledGuids.address1GUID,
+ },
+ true
+ );
+
+ return prefilledGuids;
+}
+
+add_task(async function test_change_shipping() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let prefilledGuids = await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ({ prefilledGuids: guids }) => {
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ guids.card1GUID
+ );
+ },
+ { prefilledGuids }
+ );
+
+ let shippingOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingOptions
+ );
+ is(
+ shippingOptions.selectedOptionCurrency,
+ "USD",
+ "Shipping options should be in USD"
+ );
+ is(
+ shippingOptions.optionCount,
+ 2,
+ "there should be two shipping options"
+ );
+ is(
+ shippingOptions.selectedOptionID,
+ "2",
+ "default selected should be '2'"
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.selectShippingOptionById,
+ "1"
+ );
+
+ shippingOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingOptions
+ );
+ is(
+ shippingOptions.optionCount,
+ 2,
+ "there should be two shipping options"
+ );
+ is(shippingOptions.selectedOptionID, "1", "selected should be '1'");
+
+ let paymentDetails = Object.assign(
+ {},
+ PTU.Details.twoShippingOptionsEUR,
+ PTU.Details.total1pt75EUR,
+ PTU.Details.twoDisplayItemsEUR,
+ PTU.Details.additionalDisplayItemsEUR
+ );
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ details: paymentDetails,
+ },
+ ],
+ PTU.ContentTasks.updateWith
+ );
+ info("added shipping change handler to change to EUR");
+
+ await selectPaymentDialogShippingAddressByCountry(frame, "DE");
+ info("changed shipping address to DE country");
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+ info("got shippingaddresschange event");
+
+ // verify update of shippingOptions
+ shippingOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingOptions
+ );
+ is(
+ shippingOptions.selectedOptionCurrency,
+ "EUR",
+ "Shipping options should be in EUR after the shippingaddresschange"
+ );
+ is(
+ shippingOptions.selectedOptionID,
+ "1",
+ "id:1 should still be selected"
+ );
+ is(
+ shippingOptions.selectedOptionValue,
+ "1.01",
+ "amount should be '1.01' after the shippingaddresschange"
+ );
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+ // verify update of total
+ // Note: The update includes a modifier, and modifiers must include a total
+ // so the expected total is that one
+ is(
+ content.document.querySelector("#total > currency-amount")
+ .textContent,
+ "\u20AC2.50 EUR",
+ "Check updated total currency amount"
+ );
+
+ let btn = content.document.querySelector("#view-all");
+ btn.click();
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.orderDetailsShowing;
+ },
+ "Order details show be showing now"
+ );
+
+ let container = content.document.querySelector("order-details");
+ let items = [
+ ...container.querySelectorAll(".main-list payment-details-item"),
+ ].map(item => Cu.waiveXrays(item));
+
+ // verify the updated displayItems
+ is(items.length, 2, "2 display items");
+ is(items[0].amountCurrency, "EUR", "First display item is in Euros");
+ is(items[1].amountCurrency, "EUR", "2nd display item is in Euros");
+ is(items[0].amountValue, "0.85", "First display item has 0.85 value");
+ is(items[1].amountValue, "1.70", "2nd display item has 1.70 value");
+
+ // verify the updated modifiers
+ items = [
+ ...container.querySelectorAll(
+ ".footer-items-list payment-details-item"
+ ),
+ ].map(item => Cu.waiveXrays(item));
+ is(items.length, 1, "1 additional display item");
+ is(items[0].amountCurrency, "EUR", "First display item is in Euros");
+ is(items[0].amountValue, "1.00", "First display item has 1.00 value");
+ btn.click();
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "123",
+ }
+ );
+
+ info("clicking pay");
+ await loginAndCompletePayment(frame);
+
+ // Add a handler to complete the payment above.
+ info("acknowledging the completion from the merchant page");
+ let result = await SpecialPowers.spawn(
+ browser,
+ [],
+ PTU.ContentTasks.addCompletionHandler
+ );
+ is(result.response.methodName, "basic-card", "Check methodName");
+
+ let { shippingAddress } = result.response;
+ let expectedAddress = PTU.Addresses.TimBL2;
+ checkPaymentAddressMatchesStorageAddress(
+ shippingAddress,
+ expectedAddress,
+ "Shipping address"
+ );
+
+ let { methodDetails } = result;
+ checkPaymentMethodDetailsMatchesCard(
+ methodDetails,
+ PTU.BasicCards.JohnDoe,
+ "Payment method"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ await cleanupFormAutofillStorage();
+});
+
+add_task(async function test_default_shippingOptions_noneSelected() {
+ await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let shippingOptionDetails = Object.assign(
+ deepClone(PTU.Details.twoShippingOptions),
+ PTU.Details.total2USD
+ );
+ info("make sure no shipping options are selected");
+ shippingOptionDetails.shippingOptions.forEach(opt => delete opt.selected);
+
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: shippingOptionDetails,
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ let shippingOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingOptions
+ );
+ is(
+ shippingOptions.optionCount,
+ 2,
+ "there should be two shipping options"
+ );
+ is(
+ shippingOptions.selectedOptionIndex,
+ "-1",
+ "no options should be selected"
+ );
+
+ let shippingOptionDetailsEUR = deepClone(
+ PTU.Details.twoShippingOptionsEUR
+ );
+ info("prepare EUR options by deselecting all and giving unique IDs");
+ shippingOptionDetailsEUR.shippingOptions.forEach(opt => {
+ opt.selected = false;
+ opt.id += "-EUR";
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.selectShippingOptionById,
+ "1"
+ );
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ details: Object.assign(
+ shippingOptionDetailsEUR,
+ PTU.Details.total1pt75EUR
+ ),
+ },
+ ],
+ PTU.ContentTasks.updateWith
+ );
+ info("added shipping change handler to change to EUR");
+
+ await selectPaymentDialogShippingAddressByCountry(frame, "DE");
+ info("changed shipping address to DE country");
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+ info("got shippingaddresschange event");
+
+ shippingOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingOptions
+ );
+ is(
+ shippingOptions.optionCount,
+ 2,
+ "there should be two shipping options"
+ );
+ is(
+ shippingOptions.selectedOptionIndex,
+ "-1",
+ "no options should be selected again"
+ );
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ await cleanupFormAutofillStorage();
+});
+
+add_task(async function test_default_shippingOptions_allSelected() {
+ await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let shippingOptionDetails = Object.assign(
+ deepClone(PTU.Details.twoShippingOptions),
+ PTU.Details.total2USD
+ );
+ info("make sure no shipping options are selected");
+ shippingOptionDetails.shippingOptions.forEach(
+ opt => (opt.selected = true)
+ );
+
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: shippingOptionDetails,
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ let shippingOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingOptions
+ );
+ is(
+ shippingOptions.selectedOptionCurrency,
+ "USD",
+ "Shipping options should be in USD"
+ );
+ is(
+ shippingOptions.optionCount,
+ 2,
+ "there should be two shipping options"
+ );
+ is(
+ shippingOptions.selectedOptionID,
+ "2",
+ "default selected should be the last selected=true"
+ );
+
+ let shippingOptionDetailsEUR = deepClone(
+ PTU.Details.twoShippingOptionsEUR
+ );
+ info("prepare EUR options by selecting all and giving unique IDs");
+ shippingOptionDetailsEUR.shippingOptions.forEach(opt => {
+ opt.selected = true;
+ opt.id += "-EUR";
+ });
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ details: Object.assign(
+ shippingOptionDetailsEUR,
+ PTU.Details.total1pt75EUR
+ ),
+ },
+ ],
+ PTU.ContentTasks.updateWith
+ );
+ info("added shipping change handler to change to EUR");
+
+ await selectPaymentDialogShippingAddressByCountry(frame, "DE");
+ info("changed shipping address to DE country");
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+ info("got shippingaddresschange event");
+
+ shippingOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingOptions
+ );
+ is(
+ shippingOptions.selectedOptionCurrency,
+ "EUR",
+ "Shipping options should be in EUR"
+ );
+ is(
+ shippingOptions.optionCount,
+ 2,
+ "there should be two shipping options"
+ );
+ is(
+ shippingOptions.selectedOptionID,
+ "2-EUR",
+ "default selected should be the last selected=true"
+ );
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ await cleanupFormAutofillStorage();
+});
+
+add_task(async function test_no_shippingchange_without_shipping() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let prefilledGuids = await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ({ prefilledGuids: guids }) => {
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ guids.card1GUID
+ );
+ },
+ { prefilledGuids }
+ );
+
+ SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.ensureNoPaymentRequestEvent
+ );
+ info("added shipping change handler");
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "456",
+ }
+ );
+
+ info("clicking pay");
+ await loginAndCompletePayment(frame);
+
+ // Add a handler to complete the payment above.
+ info("acknowledging the completion from the merchant page");
+ let result = await SpecialPowers.spawn(
+ browser,
+ [],
+ PTU.ContentTasks.addCompletionHandler
+ );
+ is(result.response.methodName, "basic-card", "Check methodName");
+
+ let actualShippingAddress = result.response.shippingAddress;
+ ok(
+ actualShippingAddress === null,
+ "Check that shipping address is null with requestShipping:false"
+ );
+
+ let { methodDetails } = result;
+ checkPaymentMethodDetailsMatchesCard(
+ methodDetails,
+ PTU.BasicCards.JohnDoe,
+ "Payment method"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ await cleanupFormAutofillStorage();
+});
+
+add_task(async function test_address_edit() {
+ await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ options: PTU.Options.requestShippingOption,
+ });
+
+ let addressOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingAddresses
+ );
+ info("initial addressOptions: " + JSON.stringify(addressOptions));
+ let selectedIndex = addressOptions.selectedOptionIndex;
+
+ is(selectedIndex, -1, "No address should be selected initially");
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.promisePaymentRequestEvent
+ );
+
+ info("selecting the US address");
+ await selectPaymentDialogShippingAddressByCountry(frame, "US");
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ addressOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingAddresses
+ );
+ info("initial addressOptions: " + JSON.stringify(addressOptions));
+ selectedIndex = addressOptions.selectedOptionIndex;
+ let selectedAddressGuid = addressOptions.options[selectedIndex].guid;
+ let selectedAddress = await formAutofillStorage.addresses.get(
+ selectedAddressGuid
+ );
+
+ // US address is inserted first, then German address, so German address
+ // has more recent timeLastModified and will appear at the top of the list.
+ is(selectedIndex, 1, "Second address should be selected");
+ ok(
+ selectedAddress,
+ "Selected address does exist in the address collection"
+ );
+ is(selectedAddress.country, "US", "Expected initial country value");
+
+ info("Updating the address directly in the store");
+ await formAutofillStorage.addresses.update(
+ selectedAddress.guid,
+ {
+ country: "CA",
+ },
+ true
+ );
+
+ addressOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingAddresses
+ );
+ info("updated addressOptions: " + JSON.stringify(addressOptions));
+ selectedIndex = addressOptions.selectedOptionIndex;
+ let newSelectedAddressGuid = addressOptions.options[selectedIndex].guid;
+
+ is(
+ newSelectedAddressGuid,
+ selectedAddressGuid,
+ "Selected guid hasnt changed"
+ );
+ selectedAddress = await formAutofillStorage.addresses.get(
+ selectedAddressGuid
+ );
+
+ is(selectedIndex, 1, "Second address should be selected");
+ is(selectedAddress.country, "CA", "Expected changed country value");
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+
+ await cleanupFormAutofillStorage();
+});
+
+add_task(async function test_address_removal() {
+ await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ options: PTU.Options.requestShippingOption,
+ });
+
+ info("selecting the US address");
+ await selectPaymentDialogShippingAddressByCountry(frame, "US");
+
+ let addressOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingAddresses
+ );
+ info("initial addressOptions: " + JSON.stringify(addressOptions));
+ let selectedIndex = addressOptions.selectedOptionIndex;
+ let selectedAddressGuid = addressOptions.options[selectedIndex].guid;
+
+ // US address is inserted first, then German address, so German address
+ // has more recent timeLastModified and will appear at the top of the list.
+ is(selectedIndex, 1, "Second address should be selected");
+ is(
+ addressOptions.options.length,
+ 2,
+ "Should be 2 address options initially"
+ );
+
+ info("Remove the selected address from the store");
+ await formAutofillStorage.addresses.remove(selectedAddressGuid);
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.promisePaymentRequestEvent
+ );
+
+ addressOptions = await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.getShippingAddresses
+ );
+ info("updated addressOptions: " + JSON.stringify(addressOptions));
+ selectedIndex = addressOptions.selectedOptionIndex;
+
+ is(
+ selectedIndex,
+ -1,
+ "No replacement address should be selected after deletion"
+ );
+ is(addressOptions.options.length, 1, "Should now be 1 address option");
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+
+ await cleanupFormAutofillStorage();
+});
diff --git a/browser/components/payments/test/browser/browser_dropdowns.js b/browser/components/payments/test/browser/browser_dropdowns.js
new file mode 100644
index 0000000000..f84ff6e252
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_dropdowns.js
@@ -0,0 +1,82 @@
+"use strict";
+
+add_task(async function test_dropdown() {
+ await addSampleAddressesAndBasicCard();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ details: PTU.Details.total60USD,
+ methodData: [PTU.MethodData.basicCard],
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ let popupset = frame.ownerDocument.querySelector("popupset");
+ ok(popupset, "popupset exists");
+ let popupshownPromise = BrowserTestUtils.waitForEvent(
+ popupset,
+ "popupshown"
+ );
+
+ info("switch to the address add page");
+ await spawnPaymentDialogTask(
+ frame,
+ async function changeToAddressAddPage() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let addLink = content.document.querySelector(
+ "address-picker.shipping-related .add-link"
+ );
+ is(addLink.textContent, "Add", "Add link text");
+
+ addLink.click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "shipping-address-page" && !state.page.guid
+ );
+ },
+ "Check add page state"
+ );
+
+ content.document
+ .querySelector("#shipping-address-page #country")
+ .scrollIntoView();
+ }
+ );
+
+ info("going to open the country <select>");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#shipping-address-page #country",
+ {},
+ frame
+ );
+
+ let event = await popupshownPromise;
+ let expectedPopupID = "ContentSelectDropdown";
+ is(
+ event.target.parentElement.id,
+ expectedPopupID,
+ "Checked menulist of opened popup"
+ );
+
+ event.target.hidePopup(true);
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
diff --git a/browser/components/payments/test/browser/browser_host_name.js b/browser/components/payments/test/browser/browser_host_name.js
new file mode 100644
index 0000000000..fb88253164
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_host_name.js
@@ -0,0 +1,50 @@
+"use strict";
+
+async function withBasicRequestDialogForOrigin(origin, dialogTaskFn) {
+ const args = {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ };
+ await spawnInDialogForMerchantTask(
+ PTU.ContentTasks.createAndShowRequest,
+ dialogTaskFn,
+ args,
+ {
+ origin,
+ }
+ );
+}
+
+add_task(async function test_host() {
+ await withBasicRequestDialogForOrigin("https://example.com", () => {
+ is(
+ content.document.querySelector("#host-name").textContent,
+ "example.com",
+ "Check basic host name"
+ );
+ });
+});
+
+add_task(async function test_host_subdomain() {
+ await withBasicRequestDialogForOrigin("https://test1.example.com", () => {
+ is(
+ content.document.querySelector("#host-name").textContent,
+ "test1.example.com",
+ "Check host name with subdomain"
+ );
+ });
+});
+
+add_task(async function test_host_IDN() {
+ await withBasicRequestDialogForOrigin(
+ "https://xn--hxajbheg2az3al.xn--jxalpdlp",
+ () => {
+ is(
+ content.document.querySelector("#host-name").textContent,
+ "\u03C0\u03B1\u03C1\u03AC\u03B4\u03B5\u03B9\u03B3\u03BC\u03B1." +
+ "\u03B4\u03BF\u03BA\u03B9\u03BC\u03AE",
+ "Check IDN domain"
+ );
+ }
+ );
+});
diff --git a/browser/components/payments/test/browser/browser_onboarding_wizard.js b/browser/components/payments/test/browser/browser_onboarding_wizard.js
new file mode 100644
index 0000000000..14d3d98440
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_onboarding_wizard.js
@@ -0,0 +1,859 @@
+"use strict";
+
+async function addAddress() {
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "add"
+ );
+ formAutofillStorage.addresses.add(PTU.Addresses.TimBL);
+ await onChanged;
+}
+
+async function addBasicCard() {
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "add"
+ );
+ formAutofillStorage.creditCards.add(PTU.BasicCards.JohnDoe);
+ await onChanged;
+}
+
+add_task(
+ async function test_onboarding_wizard_without_saved_addresses_and_saved_cards() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ cleanupFormAutofillStorage();
+
+ info("Opening the payment dialog");
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "shipping-address-page";
+ },
+ "Address page is shown first during on-boarding if there are no saved addresses"
+ );
+
+ let addressForm = content.document.querySelector(
+ "#shipping-address-page"
+ );
+ info("Checking if the address page has been rendered");
+ let addressSaveButton = addressForm.querySelector(".save-button");
+ ok(
+ content.isVisible(addressSaveButton),
+ "Address save button is rendered"
+ );
+ is(
+ addressSaveButton.textContent,
+ "Next",
+ "Address save button has the correct label during onboarding"
+ );
+
+ info(
+ "Check if the total header is visible on the address page during on-boarding"
+ );
+ let header = content.document.querySelector("header");
+ ok(
+ content.isVisible(header),
+ "Total Header is visible on the address page during on-boarding"
+ );
+ ok(header.textContent, "Total Header contains text");
+
+ info("Check if the page title is visible on the address page");
+ let addressPageTitle = addressForm.querySelector("h2");
+ ok(
+ content.isVisible(addressPageTitle),
+ "Address page title is visible"
+ );
+ is(
+ addressPageTitle.textContent,
+ "Add Shipping Address",
+ "Address page title is correctly shown"
+ );
+
+ let addressCancelButton = addressForm.querySelector(".cancel-button");
+ ok(
+ content.isVisible(addressCancelButton),
+ "The cancel button on the address page is visible"
+ );
+ });
+
+ let addOptions = {
+ addLinkSelector: "address-picker.shipping-related .add-link",
+ initialPageId: "payment-summary",
+ addressPageId: "shipping-address-page",
+ };
+
+ await fillInShippingAddressForm(
+ frame,
+ PTU.Addresses.TimBL2,
+ addOptions
+ );
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "basic-card-page";
+ },
+ "Basic card page is shown after the address page during on boarding"
+ );
+
+ let cardSaveButton = content.document.querySelector(
+ "basic-card-form .save-button"
+ );
+ is(
+ cardSaveButton.textContent,
+ "Next",
+ "Card save button has the correct label during onboarding"
+ );
+ ok(content.isVisible(cardSaveButton), "Basic card page is rendered");
+
+ let basicCardTitle = content.document.querySelector(
+ "basic-card-form h2"
+ );
+ ok(
+ content.isVisible(basicCardTitle),
+ "Basic card page title is visible"
+ );
+ is(
+ basicCardTitle.textContent,
+ "Add Credit Card",
+ "Basic card page title is correctly shown"
+ );
+
+ info(
+ "Check if the correct billing address is selected in the basic card page"
+ );
+ PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ let billingAddressSelect = content.document.querySelector(
+ "#billingAddressGUID"
+ );
+ return (
+ state.selectedShippingAddress == billingAddressSelect.value
+ );
+ },
+ "Shipping address is selected as the billing address"
+ );
+ });
+
+ await fillInCardForm(frame, {
+ ["cc-csc"]: "123",
+ ...PTU.BasicCards.JohnDoe,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "Payment summary page is shown after the basic card page during on boarding"
+ );
+
+ let cancelButton = content.document.querySelector("#cancel");
+ ok(
+ content.isVisible(cancelButton),
+ "Payment summary page is rendered"
+ );
+ });
+
+ info("Closing the payment dialog");
+ spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.manuallyClickCancel
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ }
+);
+
+add_task(
+ async function test_onboarding_wizard_with_saved_addresses_and_saved_cards() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ addSampleAddressesAndBasicCard();
+
+ info("Opening the payment dialog");
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "Payment summary page is shown first when there are saved addresses and saved cards"
+ );
+
+ info("Checking if the payment summary page is now visible");
+ let cancelButton = content.document.querySelector("#cancel");
+ ok(
+ content.isVisible(cancelButton),
+ "Payment summary page is rendered"
+ );
+
+ info("Check if the total header is visible on payments summary page");
+ let header = content.document.querySelector("header");
+ ok(
+ content.isVisible(header),
+ "Total Header is visible on the payment summary page"
+ );
+ ok(header.textContent, "Total Header contains text");
+
+ // Click on the Add/Edit buttons in the payment summary page to check if
+ // the total header is visible on the address page and the basic card page.
+ let buttons = [
+ "address-picker[selected-state-key='selectedShippingAddress'] .add-link",
+ "address-picker[selected-state-key='selectedShippingAddress'] .edit-link",
+ "payment-method-picker .add-link",
+ "payment-method-picker .edit-link",
+ ];
+ for (let button of buttons) {
+ content.document.querySelector(button).click();
+ if (button.startsWith("address")) {
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "shipping-address-page";
+ },
+ "Shipping address page is shown"
+ );
+ } else {
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "basic-card-page";
+ },
+ "Basic card page is shown"
+ );
+ }
+
+ ok(
+ !content.isVisible(header),
+ "Total Header is hidden on the address/basic card page"
+ );
+
+ if (button.startsWith("address")) {
+ content.document
+ .querySelector("#shipping-address-page .back-button")
+ .click();
+ } else {
+ content.document
+ .querySelector("basic-card-form .back-button")
+ .click();
+ }
+ }
+ });
+
+ info("Closing the payment dialog");
+ spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.manuallyClickCancel
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+
+ cleanupFormAutofillStorage();
+ }
+ );
+ }
+);
+
+add_task(
+ async function test_onboarding_wizard_with_saved_addresses_and_no_saved_cards() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ addAddress();
+
+ info("Opening the payment dialog");
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "basic-card-page";
+ },
+ "Basic card page is shown first if there are saved addresses during on boarding"
+ );
+
+ info("Checking if the basic card page has been rendered");
+ let cardSaveButton = content.document.querySelector(
+ "basic-card-form .save-button"
+ );
+ ok(content.isVisible(cardSaveButton), "Basic card page is rendered");
+
+ info(
+ "Check if the total header is visible on the basic card page during on-boarding"
+ );
+ let header = content.document.querySelector("header");
+ ok(
+ content.isVisible(header),
+ "Total Header is visible on the basic card page during on-boarding"
+ );
+ ok(header.textContent, "Total Header contains text");
+
+ let cardCancelButton = content.document.querySelector(
+ "basic-card-form .cancel-button"
+ );
+ ok(
+ content.isVisible(cardCancelButton),
+ "Cancel button is visible on the basic card page"
+ );
+
+ let cardBackButton = content.document.querySelector(
+ "basic-card-form .back-button"
+ );
+ ok(
+ !content.isVisible(cardBackButton),
+ "Back button is hidden on the basic card page when it is shown first during onboarding"
+ );
+ });
+
+ // Do not await for this task since the dialog may close before the task resolves.
+ spawnPaymentDialogTask(frame, () => {
+ content.document
+ .querySelector("basic-card-form .cancel-button")
+ .click();
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+
+ cleanupFormAutofillStorage();
+ }
+ );
+ }
+);
+
+add_task(
+ async function test_onboarding_wizard_without_saved_address_with_saved_cards() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ cleanupFormAutofillStorage();
+ addBasicCard();
+
+ info("Opening the payment dialog");
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ let addOptions = {
+ addLinkSelector: "address-picker.shipping-related .add-link",
+ checkboxSelector: "#shipping-address-page .persist-checkbox",
+ initialPageId: "payment-summary",
+ addressPageId: "shipping-address-page",
+ expectPersist: true,
+ };
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "shipping-address-page";
+ },
+ "Shipping address page is shown first if there are saved addresses during on boarding"
+ );
+
+ info("Checking if the address page has been rendered");
+ let addressForm = content.document.querySelector(
+ "#shipping-address-page"
+ );
+ let addressSaveButton = addressForm.querySelector(".save-button");
+ ok(
+ content.isVisible(addressSaveButton),
+ "Address save button is rendered"
+ );
+ });
+
+ await fillInShippingAddressForm(
+ frame,
+ PTU.Addresses.TimBL2,
+ addOptions
+ );
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkSavedAndCancelButton() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "payment-summary is now visible"
+ );
+
+ let cancelButton = content.document.querySelector("#cancel");
+ ok(
+ content.isVisible(cancelButton),
+ "Payment summary page is shown next"
+ );
+ }
+ );
+
+ info("Closing the payment dialog");
+ spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.manuallyClickCancel
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ cleanupFormAutofillStorage();
+ }
+ );
+ }
+);
+
+add_task(
+ async function test_onboarding_wizard_with_requestShipping_turned_off() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ cleanupFormAutofillStorage();
+
+ info("Opening the payment dialog");
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "billing-address-page";
+ // eslint-disable-next-line max-len
+ },
+ "Billing address page is shown first during on-boarding if requestShipping is turned off"
+ );
+
+ info("Checking if the billing address page has been rendered");
+ let addressForm = content.document.querySelector(
+ "#billing-address-page"
+ );
+ let addressSaveButton = addressForm.querySelector(".save-button");
+ ok(
+ content.isVisible(addressSaveButton),
+ "Address save button is rendered"
+ );
+
+ info("Check if the page title is visible on the address page");
+ let addressPageTitle = addressForm.querySelector("h2");
+ ok(
+ content.isVisible(addressPageTitle),
+ "Address page title is visible"
+ );
+ is(
+ addressPageTitle.textContent,
+ "Add Billing Address",
+ "Address page title is correctly shown"
+ );
+ });
+
+ let addOptions = {
+ initialPageId: "basic-card-page",
+ addressPageId: "billing-address-page",
+ };
+
+ await fillInBillingAddressForm(frame, PTU.Addresses.TimBL2, addOptions);
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "basic-card-page";
+ // eslint-disable-next-line max-len
+ },
+ "Basic card page is shown after the billing address page during onboarding if requestShipping is turned off"
+ );
+
+ let cardSaveButton = content.document.querySelector(
+ "basic-card-form .save-button"
+ );
+ ok(content.isVisible(cardSaveButton), "Basic card page is rendered");
+
+ info(
+ "Check if the correct billing address is selected in the basic card page"
+ );
+ PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ let billingAddressSelect = content.document.querySelector(
+ "#billingAddressGUID"
+ );
+ return (
+ state["basic-card-page"].billingAddressGUID ==
+ billingAddressSelect.value
+ );
+ },
+ "Billing Address is correctly shown"
+ );
+ });
+
+ await fillInCardForm(frame, {
+ ["cc-csc"]: "123",
+ ...PTU.BasicCards.JohnDoe,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "payment-summary is shown after the basic card page during on boarding"
+ );
+
+ let cancelButton = content.document.querySelector("#cancel");
+ ok(
+ content.isVisible(cancelButton),
+ "Payment summary page is rendered"
+ );
+ });
+
+ info("Closing the payment dialog");
+ spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.manuallyClickCancel
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+
+ cleanupFormAutofillStorage();
+ }
+ );
+ }
+);
+
+add_task(
+ async function test_on_boarding_wizard_with_requestShipping_turned_off_with_saved_cards() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ cleanupFormAutofillStorage();
+ addBasicCard();
+
+ info("Opening the payment dialog");
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let cancelButton = content.document.querySelector("#cancel");
+ ok(
+ content.isVisible(cancelButton),
+ // eslint-disable-next-line max-len
+ "Payment summary page is shown if requestShipping is turned off and there's a saved card but no saved address"
+ );
+ });
+
+ info("Closing the payment dialog");
+ spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.manuallyClickCancel
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+
+ cleanupFormAutofillStorage();
+ }
+ );
+ }
+);
+
+add_task(
+ async function test_back_button_on_basic_card_page_during_onboarding() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ cleanupFormAutofillStorage();
+
+ info("Opening the payment dialog");
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "billing-address-page";
+ },
+ "Billing address page is shown first if there are no saved addresses " +
+ "and requestShipping is false during on boarding"
+ );
+ info("Checking if the address page has been rendered");
+ let addressSaveButton = content.document.querySelector(
+ "#billing-address-page .save-button"
+ );
+ ok(
+ content.isVisible(addressSaveButton),
+ "Address save button is rendered"
+ );
+ });
+
+ let addOptions = {
+ addLinkSelector: "address-picker.billing-related .add-link",
+ checkboxSelector: "#billing-address-page .persist-checkbox",
+ initialPageId: "basic-card-page",
+ addressPageId: "billing-address-page",
+ expectPersist: true,
+ };
+
+ await fillInBillingAddressForm(frame, PTU.Addresses.TimBL2, addOptions);
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "basic-card-page";
+ },
+ "Basic card page is shown next"
+ );
+
+ info("Checking if basic card page is rendered");
+ let basicCardBackButton = content.document.querySelector(
+ "basic-card-form .back-button"
+ );
+ ok(
+ content.isVisible(basicCardBackButton),
+ "Back button is visible on the basic card page"
+ );
+
+ info("Partially fill basic card form");
+ let field = content.document.getElementById("cc-number");
+ content.fillField(field, PTU.BasicCards.JohnDoe["cc-number"]);
+
+ info(
+ "Clicking on the back button to edit address saved in the previous step"
+ );
+ basicCardBackButton.click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "billing-address-page" &&
+ state["billing-address-page"].guid ==
+ state["basic-card-page"].billingAddressGUID
+ );
+ },
+ "Billing address page is shown again"
+ );
+
+ info("Checking if the address page has been rendered");
+ let addressForm = content.document.querySelector(
+ "#billing-address-page"
+ );
+ let addressSaveButton = addressForm.querySelector(".save-button");
+ ok(
+ content.isVisible(addressSaveButton),
+ "Address save button is rendered"
+ );
+
+ info(
+ "Checking if the address saved in the last step is correctly loaded in the form"
+ );
+ field = addressForm.querySelector("#given-name");
+ is(
+ field.value,
+ PTU.Addresses.TimBL2["given-name"],
+ "Given name field value is correctly loaded"
+ );
+
+ info("Editing the address and saving again");
+ content.fillField(field, "John");
+ addressSaveButton.click();
+
+ info("Checking if the address was correctly edited");
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "basic-card-page" &&
+ // eslint-disable-next-line max-len
+ state.savedAddresses[
+ state["basic-card-page"].billingAddressGUID
+ ]["given-name"] == "John"
+ );
+ },
+ "Address was correctly edited and saved"
+ );
+
+ // eslint-disable-next-line max-len
+ info(
+ "Checking if the basic card form is now rendered and if the field values from before are preserved"
+ );
+ let basicCardCancelButton = content.document.querySelector(
+ "basic-card-form .cancel-button"
+ );
+ ok(
+ content.isVisible(basicCardCancelButton),
+ "Cancel button is visible on the basic card page"
+ );
+ field = content.document.getElementById("cc-number");
+ is(
+ field.value,
+ PTU.BasicCards.JohnDoe["cc-number"],
+ "Values in the form are preserved"
+ );
+ });
+
+ info("Closing the payment dialog");
+ spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.manuallyClickCancel
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+
+ cleanupFormAutofillStorage();
+ }
+ );
+ }
+);
diff --git a/browser/components/payments/test/browser/browser_openPreferences.js b/browser/components/payments/test/browser/browser_openPreferences.js
new file mode 100644
index 0000000000..5d8c71f58b
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_openPreferences.js
@@ -0,0 +1,93 @@
+"use strict";
+
+const methodData = [PTU.MethodData.basicCard];
+const details = Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+);
+
+add_task(async function setup_once() {
+ // add an address and card to avoid the FTU sequence
+ await addSampleAddressesAndBasicCard(
+ [PTU.Addresses.TimBL],
+ [PTU.BasicCards.JohnDoe]
+ );
+});
+
+add_task(async function test_openPreferences() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData,
+ details,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ let prefsTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:preferences#privacy-form-autofill"
+ );
+
+ let prefsLoadedPromise = TestUtils.topicObserved("sync-pane-loaded");
+
+ await spawnPaymentDialogTask(
+ frame,
+ function verifyPrefsLink({ isMac }) {
+ let manageTextEl = content.document.querySelector(".manage-text");
+
+ let expectedVisibleEl;
+ if (isMac) {
+ expectedVisibleEl = manageTextEl.querySelector(
+ ":scope > span[data-os='mac']"
+ );
+ ok(
+ manageTextEl.innerText.includes("Preferences"),
+ "Visible string includes 'Preferences'"
+ );
+ ok(
+ !manageTextEl.innerText.includes("Options"),
+ "Visible string includes 'Options'"
+ );
+ } else {
+ expectedVisibleEl = manageTextEl.querySelector(
+ ":scope > span:not([data-os='mac'])"
+ );
+ ok(
+ !manageTextEl.innerText.includes("Preferences"),
+ "Visible string includes 'Preferences'"
+ );
+ ok(
+ manageTextEl.innerText.includes("Options"),
+ "Visible string includes 'Options'"
+ );
+ }
+
+ let prefsLink = expectedVisibleEl.querySelector("a");
+ ok(prefsLink, "Preferences link should exist");
+ prefsLink.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(prefsLink, {}, content);
+ },
+ {
+ isMac: AppConstants.platform == "macosx",
+ }
+ );
+
+ let prefsTab = await prefsTabPromise;
+ ok(prefsTab, "Ensure a tab was opened");
+ await prefsLoadedPromise;
+
+ await BrowserTestUtils.removeTab(prefsTab);
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
diff --git a/browser/components/payments/test/browser/browser_payerRequestedFields.js b/browser/components/payments/test/browser/browser_payerRequestedFields.js
new file mode 100644
index 0000000000..e519a1ae22
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_payerRequestedFields.js
@@ -0,0 +1,154 @@
+/* eslint-disable no-shadow */
+
+"use strict";
+
+async function setup() {
+ await setupFormAutofillStorage();
+ await cleanupFormAutofillStorage();
+ // add an address and card to avoid the FTU sequence
+ let prefilledGuids = await addSampleAddressesAndBasicCard(
+ [PTU.Addresses.TimBL],
+ [PTU.BasicCards.JohnDoe]
+ );
+
+ info("associating the card with the billing address");
+ await formAutofillStorage.creditCards.update(
+ prefilledGuids.card1GUID,
+ {
+ billingAddressGUID: prefilledGuids.address1GUID,
+ },
+ true
+ );
+
+ return prefilledGuids;
+}
+
+/*
+ * Test that the payerRequested* fields are marked as required
+ * on the payer address form but aren't marked as required on
+ * the shipping address form.
+ */
+add_task(async function test_add_link() {
+ await setup();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: {
+ ...PTU.Options.requestShipping,
+ ...PTU.Options.requestPayerNameEmailAndPhone,
+ },
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await navigateToAddAddressPage(frame, {
+ addLinkSelector: "address-picker.payer-related .add-link",
+ initialPageId: "payment-summary",
+ addressPageId: "payer-address-page",
+ expectPersist: true,
+ });
+
+ await spawnPaymentDialogTask(frame, async () => {
+ let { PaymentTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let addressForm = content.document.querySelector("#payer-address-page");
+ let title = addressForm.querySelector("h2");
+ is(title.textContent, "Add Payer Contact", "Page title should be set");
+
+ let saveButton = addressForm.querySelector(".save-button");
+ is(saveButton.textContent, "Next", "Save button has the correct label");
+
+ info("check that payer requested fields are marked as required");
+ for (let selector of [
+ "#given-name",
+ "#family-name",
+ "#email",
+ "#tel",
+ ]) {
+ let element = addressForm.querySelector(selector);
+ ok(element.required, selector + " should be required");
+ }
+
+ let backButton = addressForm.querySelector(".back-button");
+ ok(
+ content.isVisible(backButton),
+ "Back button is visible on the payer address page"
+ );
+ backButton.click();
+
+ await PaymentTestUtils.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "Switched back to payment-summary from payer address form"
+ );
+ });
+
+ await navigateToAddAddressPage(frame, {
+ addLinkSelector: "address-picker.shipping-related .add-link",
+ addressPageId: "shipping-address-page",
+ initialPageId: "payment-summary",
+ expectPersist: true,
+ });
+
+ await spawnPaymentDialogTask(frame, async () => {
+ let { PaymentTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let addressForm = content.document.querySelector(
+ "#shipping-address-page"
+ );
+ let title = addressForm.querySelector("address-form h2");
+ is(
+ title.textContent,
+ "Add Shipping Address",
+ "Page title should be set"
+ );
+
+ let saveButton = addressForm.querySelector(".save-button");
+ is(saveButton.textContent, "Next", "Save button has the correct label");
+
+ ok(
+ !addressForm.querySelector("#tel").required,
+ "#tel should not be required"
+ );
+
+ let backButton = addressForm.querySelector(".back-button");
+ ok(
+ content.isVisible(backButton),
+ "Back button is visible on the payer address page"
+ );
+ backButton.click();
+
+ await PaymentTestUtils.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "Switched back to payment-summary from payer address form"
+ );
+ });
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+ await cleanupFormAutofillStorage();
+});
diff --git a/browser/components/payments/test/browser/browser_payment_completion.js b/browser/components/payments/test/browser/browser_payment_completion.js
new file mode 100644
index 0000000000..66807d65e6
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_payment_completion.js
@@ -0,0 +1,211 @@
+"use strict";
+
+/*
+ Test the permutations of calling complete() on the payment response and handling the case
+ where the timeout is exceeded before it is called
+*/
+
+async function setup() {
+ await setupFormAutofillStorage();
+ await cleanupFormAutofillStorage();
+ let billingAddressGUID = await addAddressRecord(PTU.Addresses.TimBL);
+ let card = Object.assign({}, PTU.BasicCards.JohnDoe, { billingAddressGUID });
+ let card1GUID = await addCardRecord(card);
+ return { address1GUID: billingAddressGUID, card1GUID };
+}
+
+add_task(async function test_complete_success() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let prefilledGuids = await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign({}, PTU.Details.total60USD),
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ({ prefilledGuids: guids }) => {
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ guids.card1GUID
+ );
+ },
+ { prefilledGuids }
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "123",
+ }
+ );
+
+ await loginAndCompletePayment(frame);
+
+ // Add a handler to complete the payment above.
+ info("acknowledging the completion from the merchant page");
+ let { completeException } = await SpecialPowers.spawn(
+ browser,
+ [{ result: "success" }],
+ PTU.ContentTasks.addCompletionHandler
+ );
+
+ ok(
+ !completeException,
+ "Expect no exception to be thrown when calling complete()"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_complete_fail() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let prefilledGuids = await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign({}, PTU.Details.total60USD),
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ({ prefilledGuids: guids }) => {
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ guids.card1GUID
+ );
+ },
+ { prefilledGuids }
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "456",
+ }
+ );
+
+ info("clicking pay");
+ await loginAndCompletePayment(frame);
+
+ info("acknowledging the completion from the merchant page");
+ let { completeException } = await SpecialPowers.spawn(
+ browser,
+ [{ result: "fail" }],
+ PTU.ContentTasks.addCompletionHandler
+ );
+ ok(
+ !completeException,
+ "Expect no exception to be thrown when calling complete()"
+ );
+
+ ok(!win.closed, "dialog shouldn't be closed yet");
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_complete_timeout() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let prefilledGuids = await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ // timeout the response asap
+ Services.prefs.setIntPref(RESPONSE_TIMEOUT_PREF, 60);
+
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign({}, PTU.Details.total60USD),
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ({ prefilledGuids: guids }) => {
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ guids.card1GUID
+ );
+ },
+ { prefilledGuids }
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "789",
+ }
+ );
+
+ info("clicking pay");
+ await loginAndCompletePayment(frame);
+
+ info("acknowledging the completion from the merchant page after a delay");
+ let { completeException } = await SpecialPowers.spawn(
+ browser,
+ [{ result: "fail", delayMs: 1000 }],
+ PTU.ContentTasks.addCompletionHandler
+ );
+ ok(
+ completeException,
+ "Expect an exception to be thrown when calling complete() too late"
+ );
+
+ ok(!win.closed, "dialog shouldn't be closed");
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
diff --git a/browser/components/payments/test/browser/browser_profile_storage.js b/browser/components/payments/test/browser/browser_profile_storage.js
new file mode 100644
index 0000000000..217ba42bb7
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_profile_storage.js
@@ -0,0 +1,303 @@
+/* eslint-disable no-shadow */
+
+"use strict";
+
+const methodData = [PTU.MethodData.basicCard];
+const details = PTU.Details.total60USD;
+
+add_task(async function test_initial_state() {
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "add"
+ );
+ let address1GUID = await formAutofillStorage.addresses.add(
+ PTU.Addresses.TimBL
+ );
+ await onChanged;
+
+ onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "add"
+ );
+ let card1GUID = await formAutofillStorage.creditCards.add(
+ PTU.BasicCards.JohnDoe
+ );
+ await onChanged;
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData,
+ details,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkInitialStore({ address1GUID, card1GUID }) {
+ info("checkInitialStore");
+ let contentWin = Cu.waiveXrays(content);
+ let {
+ savedAddresses,
+ savedBasicCards,
+ } = contentWin.document
+ .querySelector("payment-dialog")
+ .requestStore.getState();
+
+ is(
+ Object.keys(savedAddresses).length,
+ 1,
+ "Initially one savedAddresses"
+ );
+ is(
+ savedAddresses[address1GUID].name,
+ "Timothy John Berners-Lee",
+ "Check full name"
+ );
+ is(
+ savedAddresses[address1GUID].guid,
+ address1GUID,
+ "Check address guid matches key"
+ );
+
+ is(
+ Object.keys(savedBasicCards).length,
+ 1,
+ "Initially one savedBasicCards"
+ );
+ is(
+ savedBasicCards[card1GUID]["cc-number"],
+ "************1111",
+ "Check cc-number"
+ );
+ is(
+ savedBasicCards[card1GUID].guid,
+ card1GUID,
+ "Check card guid matches key"
+ );
+ is(
+ savedBasicCards[card1GUID].methodName,
+ "basic-card",
+ "Check card has a methodName of basic-card"
+ );
+ },
+ {
+ address1GUID,
+ card1GUID,
+ }
+ );
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "add"
+ );
+ info("adding an address");
+ let address2GUID = await formAutofillStorage.addresses.add(
+ PTU.Addresses.TimBL2
+ );
+ await onChanged;
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkAdd({ address1GUID, address2GUID, card1GUID }) {
+ info("checkAdd");
+
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+ let {
+ savedAddresses,
+ savedBasicCards,
+ } = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => !!state.savedAddresses[address2GUID]
+ );
+
+ let addressGUIDs = Object.keys(savedAddresses);
+ is(addressGUIDs.length, 2, "Now two savedAddresses");
+ is(addressGUIDs[0], address1GUID, "Check first address GUID");
+ is(
+ savedAddresses[address1GUID].guid,
+ address1GUID,
+ "Check address 1 guid matches key"
+ );
+ is(addressGUIDs[1], address2GUID, "Check second address GUID");
+ is(
+ savedAddresses[address2GUID].guid,
+ address2GUID,
+ "Check address 2 guid matches key"
+ );
+
+ is(
+ Object.keys(savedBasicCards).length,
+ 1,
+ "Still one savedBasicCards"
+ );
+ is(
+ savedBasicCards[card1GUID].guid,
+ card1GUID,
+ "Check card guid matches key"
+ );
+ is(
+ savedBasicCards[card1GUID].methodName,
+ "basic-card",
+ "Check card has a methodName of basic-card"
+ );
+ },
+ {
+ address1GUID,
+ address2GUID,
+ card1GUID,
+ }
+ );
+
+ onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "update"
+ );
+ info("updating the credit expiration");
+ await formAutofillStorage.creditCards.update(
+ card1GUID,
+ {
+ "cc-exp-month": 6,
+ "cc-exp-year": 2029,
+ },
+ true
+ );
+ await onChanged;
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkUpdate({ address1GUID, address2GUID, card1GUID }) {
+ info("checkUpdate");
+
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+ let {
+ savedAddresses,
+ savedBasicCards,
+ } = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => !!state.savedAddresses[address2GUID]
+ );
+
+ let addressGUIDs = Object.keys(savedAddresses);
+ is(addressGUIDs.length, 2, "Still two savedAddresses");
+ is(addressGUIDs[0], address1GUID, "Check first address GUID");
+ is(
+ savedAddresses[address1GUID].guid,
+ address1GUID,
+ "Check address 1 guid matches key"
+ );
+ is(addressGUIDs[1], address2GUID, "Check second address GUID");
+ is(
+ savedAddresses[address2GUID].guid,
+ address2GUID,
+ "Check address 2 guid matches key"
+ );
+
+ is(
+ Object.keys(savedBasicCards).length,
+ 1,
+ "Still one savedBasicCards"
+ );
+ is(
+ savedBasicCards[card1GUID].guid,
+ card1GUID,
+ "Check card guid matches key"
+ );
+ is(
+ savedBasicCards[card1GUID]["cc-exp-month"],
+ 6,
+ "Check expiry month"
+ );
+ is(
+ savedBasicCards[card1GUID]["cc-exp-year"],
+ 2029,
+ "Check expiry year"
+ );
+ is(
+ savedBasicCards[card1GUID].methodName,
+ "basic-card",
+ "Check card has a methodName of basic-card"
+ );
+ },
+ {
+ address1GUID,
+ address2GUID,
+ card1GUID,
+ }
+ );
+
+ onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "remove"
+ );
+ info("removing the first address");
+ formAutofillStorage.addresses.remove(address1GUID);
+ await onChanged;
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkRemove({ address2GUID, card1GUID }) {
+ info("checkRemove");
+
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+ let {
+ savedAddresses,
+ savedBasicCards,
+ } = await PTU.DialogContentUtils.waitForState(
+ content,
+ state => !!state.savedAddresses[address2GUID]
+ );
+
+ is(Object.keys(savedAddresses).length, 1, "Now one savedAddresses");
+ is(
+ savedAddresses[address2GUID].name,
+ "Timothy Johann Berners-Lee",
+ "Check full name"
+ );
+ is(
+ savedAddresses[address2GUID].guid,
+ address2GUID,
+ "Check address guid matches key"
+ );
+
+ is(
+ Object.keys(savedBasicCards).length,
+ 1,
+ "Still one savedBasicCards"
+ );
+ is(
+ savedBasicCards[card1GUID]["cc-number"],
+ "************1111",
+ "Check cc-number"
+ );
+ is(
+ savedBasicCards[card1GUID].guid,
+ card1GUID,
+ "Check card guid matches key"
+ );
+ },
+ {
+ address2GUID,
+ card1GUID,
+ }
+ );
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
diff --git a/browser/components/payments/test/browser/browser_request_serialization.js b/browser/components/payments/test/browser/browser_request_serialization.js
new file mode 100644
index 0000000000..63ed2d71c7
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_request_serialization.js
@@ -0,0 +1,250 @@
+"use strict";
+
+add_task(async function test_serializeRequest_displayItems() {
+ const testTask = ({ methodData, details }) => {
+ let contentWin = Cu.waiveXrays(content);
+ let store = contentWin.document.querySelector("payment-dialog")
+ .requestStore;
+ let state = store && store.getState();
+ ok(state, "got request store state");
+
+ let expected = details;
+ let actual = state.request.paymentDetails;
+ if (expected.displayItems) {
+ is(
+ actual.displayItems.length,
+ expected.displayItems.length,
+ "displayItems have same length"
+ );
+ for (let i = 0; i < actual.displayItems.length; i++) {
+ let item = actual.displayItems[i],
+ expectedItem = expected.displayItems[i];
+ is(item.label, expectedItem.label, "displayItem label matches");
+ is(
+ item.amount.value,
+ expectedItem.amount.value,
+ "displayItem label matches"
+ );
+ is(
+ item.amount.currency,
+ expectedItem.amount.currency,
+ "displayItem label matches"
+ );
+ }
+ } else {
+ is(
+ actual.displayItems,
+ null,
+ "falsey input displayItems is serialized to null"
+ );
+ }
+ };
+ const args = {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoDisplayItems,
+ PTU.Details.total32USD
+ ),
+ };
+ await spawnInDialogForMerchantTask(
+ PTU.ContentTasks.createAndShowRequest,
+ testTask,
+ args
+ );
+});
+
+add_task(async function test_serializeRequest_shippingOptions() {
+ const testTask = ({ methodData, details, options }) => {
+ let contentWin = Cu.waiveXrays(content);
+ let store = contentWin.document.querySelector("payment-dialog")
+ .requestStore;
+ let state = store && store.getState();
+ ok(state, "got request store state");
+
+ // The following test cases are conditionally todo because
+ // the spec currently does not state the shippingOptions
+ // should be null when requestShipping is not set. A future
+ // spec change (bug 1436903 comments 7-12) will fix this.
+ let cond_is = options && options.requestShipping ? is : todo_is;
+
+ let expected = details;
+ let actual = state.request.paymentDetails;
+ if (expected.shippingOptions) {
+ cond_is(
+ actual.shippingOptions.length,
+ expected.shippingOptions.length,
+ "shippingOptions have same length"
+ );
+ for (let i = 0; i < actual.shippingOptions.length; i++) {
+ let item = actual.shippingOptions[i],
+ expectedItem = expected.shippingOptions[i];
+ cond_is(item.label, expectedItem.label, "shippingOption label matches");
+ cond_is(
+ item.amount.value,
+ expectedItem.amount.value,
+ "shippingOption label matches"
+ );
+ cond_is(
+ item.amount.currency,
+ expectedItem.amount.currency,
+ "shippingOption label matches"
+ );
+ }
+ } else {
+ cond_is(
+ actual.shippingOptions,
+ null,
+ "falsey input shippingOptions is serialized to null"
+ );
+ }
+ };
+
+ const argsTestCases = [
+ {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ },
+ {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ },
+ ];
+ for (let args of argsTestCases) {
+ await spawnInDialogForMerchantTask(
+ PTU.ContentTasks.createAndShowRequest,
+ testTask,
+ args
+ );
+ }
+});
+
+add_task(async function test_serializeRequest_paymentMethods() {
+ const testTask = ({ methodData, details }) => {
+ let contentWin = Cu.waiveXrays(content);
+ let store = contentWin.document.querySelector("payment-dialog")
+ .requestStore;
+ let state = store && store.getState();
+ ok(state, "got request store state");
+
+ let result = state.request;
+ is(result.paymentMethods.length, 2, "Correct number of payment methods");
+ ok(
+ result.paymentMethods[0].supportedMethods &&
+ result.paymentMethods[1].supportedMethods,
+ "Both payment methods look valid"
+ );
+
+ let cardMethod = result.paymentMethods.find(
+ m => m.supportedMethods == "basic-card"
+ );
+ is(
+ cardMethod.data.supportedNetworks.length,
+ 2,
+ "Correct number of supportedNetworks"
+ );
+ ok(
+ cardMethod.data.supportedNetworks.includes("visa") &&
+ cardMethod.data.supportedNetworks.includes("mastercard"),
+ "Got the expected supportedNetworks contents"
+ );
+ };
+ let basicCardMethod = Object.assign({}, PTU.MethodData.basicCard, {
+ data: {
+ supportedNetworks: ["visa", "mastercard"],
+ },
+ });
+ const args = {
+ methodData: [basicCardMethod, PTU.MethodData.bobPay],
+ details: PTU.Details.total60USD,
+ };
+ await spawnInDialogForMerchantTask(
+ PTU.ContentTasks.createAndShowRequest,
+ testTask,
+ args
+ );
+});
+
+add_task(async function test_serializeRequest_modifiers() {
+ const testTask = ({ methodData, details }) => {
+ let contentWin = Cu.waiveXrays(content);
+ let store = contentWin.document.querySelector("payment-dialog")
+ .requestStore;
+ let state = store && store.getState();
+ ok(state, "got request store state");
+
+ let expected = details;
+ let actual = state.request.paymentDetails;
+
+ is(
+ actual.modifiers.length,
+ expected.modifiers.length,
+ "modifiers have same length"
+ );
+ for (let i = 0; i < actual.modifiers.length; i++) {
+ let item = actual.modifiers[i],
+ expectedItem = expected.modifiers[i];
+ is(
+ item.supportedMethods,
+ expectedItem.supportedMethods,
+ "modifier supportedMethods matches"
+ );
+
+ is(
+ item.additionalDisplayItems[0].label,
+ expectedItem.additionalDisplayItems[0].label,
+ "additionalDisplayItems label matches"
+ );
+ is(
+ item.additionalDisplayItems[0].amount.value,
+ expectedItem.additionalDisplayItems[0].amount.value,
+ "additionalDisplayItems amount value matches"
+ );
+ is(
+ item.additionalDisplayItems[0].amount.currency,
+ expectedItem.additionalDisplayItems[0].amount.currency,
+ "additionalDisplayItems amount currency matches"
+ );
+
+ is(
+ item.total.label,
+ expectedItem.total.label,
+ "modifier total label matches"
+ );
+ is(
+ item.total.amount.value,
+ expectedItem.total.amount.value,
+ "modifier label matches"
+ );
+ is(
+ item.total.amount.currency,
+ expectedItem.total.amount.currency,
+ "modifier total currency matches"
+ );
+ }
+ };
+
+ const args = {
+ methodData: [PTU.MethodData.basicCard, PTU.MethodData.bobPay],
+ details: Object.assign(
+ {},
+ PTU.Details.twoDisplayItems,
+ PTU.Details.bobPayPaymentModifier,
+ PTU.Details.total2USD
+ ),
+ };
+ await spawnInDialogForMerchantTask(
+ PTU.ContentTasks.createAndShowRequest,
+ testTask,
+ args
+ );
+});
diff --git a/browser/components/payments/test/browser/browser_request_shipping.js b/browser/components/payments/test/browser/browser_request_shipping.js
new file mode 100644
index 0000000000..480cd313ef
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_request_shipping.js
@@ -0,0 +1,121 @@
+"use strict";
+
+add_task(async function setup() {
+ await addSampleAddressesAndBasicCard();
+});
+
+add_task(async function test_request_shipping_present() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ for (let [shippingKey, shippingString] of [
+ [null, "Shipping Address"],
+ ["shipping", "Shipping Address"],
+ ["delivery", "Delivery Address"],
+ ["pickup", "Pickup Address"],
+ ]) {
+ let options = {
+ requestShipping: true,
+ };
+ if (shippingKey) {
+ options.shippingType = shippingKey;
+ }
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ([aShippingKey, aShippingString]) => {
+ let shippingOptionPicker = content.document.querySelector(
+ "shipping-option-picker"
+ );
+ ok(
+ content.isVisible(shippingOptionPicker),
+ "shipping-option-picker should be visible"
+ );
+ const addressSelector =
+ "address-picker[selected-state-key='selectedShippingAddress']";
+ let shippingAddressPicker = content.document.querySelector(
+ addressSelector
+ );
+ ok(
+ content.isVisible(shippingAddressPicker),
+ "shipping address picker should be visible"
+ );
+ let shippingOption = shippingAddressPicker.querySelector("label");
+ is(
+ shippingOption.textContent,
+ aShippingString,
+ "Label should be match shipping type: " + aShippingKey
+ );
+ },
+ [shippingKey, shippingString]
+ );
+
+ spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.manuallyClickCancel
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ }
+ );
+});
+
+add_task(async function test_request_shipping_not_present() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(frame, async () => {
+ let shippingOptionPicker = content.document.querySelector(
+ "shipping-option-picker"
+ );
+ ok(
+ content.isHidden(shippingOptionPicker),
+ "shipping-option-picker should not be visible"
+ );
+ const addressSelector =
+ "address-picker[selected-state-key='selectedShippingAddress']";
+ let shippingAddress = content.document.querySelector(addressSelector);
+ ok(
+ content.isHidden(shippingAddress),
+ "shipping address picker should not be visible"
+ );
+ });
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
diff --git a/browser/components/payments/test/browser/browser_retry.js b/browser/components/payments/test/browser/browser_retry.js
new file mode 100644
index 0000000000..6998bac874
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_retry.js
@@ -0,0 +1,169 @@
+"use strict";
+
+/**
+ * Test the merchant calling .retry().
+ */
+
+async function setup() {
+ await setupFormAutofillStorage();
+ await cleanupFormAutofillStorage();
+ let billingAddressGUID = await addAddressRecord(PTU.Addresses.TimBL);
+ let card = Object.assign({}, PTU.BasicCards.JohnDoe, { billingAddressGUID });
+ let card1GUID = await addCardRecord(card);
+ return { address1GUID: billingAddressGUID, card1GUID };
+}
+
+add_task(async function test_retry_with_genericError() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let prefilledGuids = await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign({}, PTU.Details.total60USD),
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ({ prefilledGuids: guids }) => {
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ guids.card1GUID
+ );
+ },
+ { prefilledGuids }
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "123",
+ }
+ );
+
+ info("clicking the button to try pay the 1st time");
+ await loginAndCompletePayment(frame);
+
+ let retryUpdatePromise = spawnPaymentDialogTask(
+ frame,
+ async function checkDialog() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ ({ request }) => {
+ return request.completeStatus === "processing";
+ },
+ "Wait for completeStatus from pay button click"
+ );
+
+ is(
+ state.request.completeStatus,
+ "processing",
+ "Check completeStatus is processing"
+ );
+ is(
+ state.request.paymentDetails.error,
+ "",
+ "Check error string is empty"
+ );
+ ok(state.changesPrevented, "Changes prevented");
+
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ ({ request }) => {
+ return request.completeStatus === "";
+ },
+ "Wait for completeStatus from DOM update"
+ );
+
+ is(state.request.completeStatus, "", "Check completeStatus");
+ is(
+ state.request.paymentDetails.error,
+ "My generic error",
+ "Check error string in state"
+ );
+ ok(!state.changesPrevented, "Changes no longer prevented");
+ is(
+ state.page.id,
+ "payment-summary",
+ "Check still on payment-summary"
+ );
+
+ ok(
+ content.document
+ .querySelector("payment-dialog")
+ .innerText.includes("My generic error"),
+ "Check error visibility"
+ );
+ }
+ );
+
+ // Add a handler to retry the payment above.
+ info("Tell merchant page to retry with an error string");
+ let retryPromise = SpecialPowers.spawn(
+ browser,
+ [
+ {
+ delayMs: 1000,
+ validationErrors: {
+ error: "My generic error",
+ },
+ },
+ ],
+ PTU.ContentTasks.addRetryHandler
+ );
+
+ await retryUpdatePromise;
+ await loginAndCompletePayment(frame);
+
+ // We can only check the retry response after the closing as it only resolves upon complete.
+ let { retryException } = await retryPromise;
+ ok(
+ !retryException,
+ "Expect no exception to be thrown when calling retry()"
+ );
+
+ // Add a handler to complete the payment above.
+ info("acknowledging the completion from the merchant page");
+ let result = await SpecialPowers.spawn(
+ browser,
+ [],
+ PTU.ContentTasks.addCompletionHandler
+ );
+
+ // Verify response has the expected properties
+ let expectedDetails = Object.assign(
+ {
+ "cc-security-code": "123",
+ },
+ PTU.BasicCards.JohnDoe
+ );
+
+ checkPaymentMethodDetailsMatchesCard(
+ result.response.details,
+ expectedDetails,
+ "Check response payment details"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
diff --git a/browser/components/payments/test/browser/browser_retry_fieldErrors.js b/browser/components/payments/test/browser/browser_retry_fieldErrors.js
new file mode 100644
index 0000000000..12bbee2c1d
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_retry_fieldErrors.js
@@ -0,0 +1,882 @@
+"use strict";
+
+/**
+ * Test the merchant calling .retry() with field-specific errors.
+ */
+
+async function setup() {
+ await setupFormAutofillStorage();
+ await cleanupFormAutofillStorage();
+ // add 2 addresses and 2 cards to avoid the FTU sequence and test address errors
+ let prefilledGuids = await addSampleAddressesAndBasicCard(
+ [PTU.Addresses.TimBL, PTU.Addresses.TimBL2],
+ [PTU.BasicCards.JaneMasterCard, PTU.BasicCards.JohnDoe]
+ );
+
+ info("associating card1 with a billing address");
+ await formAutofillStorage.creditCards.update(
+ prefilledGuids.card1GUID,
+ {
+ billingAddressGUID: prefilledGuids.address1GUID,
+ },
+ true
+ );
+ info("associating card2 with a billing address");
+ await formAutofillStorage.creditCards.update(
+ prefilledGuids.card2GUID,
+ {
+ billingAddressGUID: prefilledGuids.address1GUID,
+ },
+ true
+ );
+
+ return prefilledGuids;
+}
+
+add_task(async function test_retry_with_shippingAddressErrors() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let prefilledGuids = await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total60USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await selectPaymentDialogShippingAddressByCountry(frame, "DE");
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ({ prefilledGuids: guids }) => {
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ guids.card2GUID
+ );
+ },
+ { prefilledGuids }
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "123",
+ }
+ );
+
+ info("clicking the button to try pay the 1st time");
+ await loginAndCompletePayment(frame);
+
+ let retryUpdatePromise = spawnPaymentDialogTask(
+ frame,
+ async function checkDialog() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ ({ request }) => {
+ return request.completeStatus === "processing";
+ },
+ "Wait for completeStatus from pay button click"
+ );
+
+ is(
+ state.request.completeStatus,
+ "processing",
+ "Check completeStatus is processing"
+ );
+ is(
+ state.request.paymentDetails.shippingAddressErrors.country,
+ undefined,
+ "Check country error string is empty"
+ );
+ ok(state.changesPrevented, "Changes prevented");
+
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ ({ request }) => {
+ return request.completeStatus === "";
+ },
+ "Wait for completeStatus from DOM update"
+ );
+
+ is(state.request.completeStatus, "", "Check completeStatus");
+ is(
+ state.request.paymentDetails.shippingAddressErrors.country,
+ "Can only ship to USA",
+ "Check country error string in state"
+ );
+ ok(!state.changesPrevented, "Changes no longer prevented");
+ is(
+ state.page.id,
+ "payment-summary",
+ "Check still on payment-summary"
+ );
+
+ ok(
+ content.document
+ .querySelector("#payment-summary")
+ .innerText.includes("Can only ship to USA"),
+ "Check error visibility on summary page"
+ );
+ ok(
+ content.document.getElementById("pay").disabled,
+ "Pay button should be disabled until the field error is addressed"
+ );
+ }
+ );
+
+ // Add a handler to retry the payment above.
+ info("Tell merchant page to retry with a country error string");
+ let retryPromise = SpecialPowers.spawn(
+ browser,
+ [
+ {
+ delayMs: 1000,
+ validationErrors: {
+ shippingAddress: {
+ country: "Can only ship to USA",
+ },
+ },
+ },
+ ],
+ PTU.ContentTasks.addRetryHandler
+ );
+
+ await retryUpdatePromise;
+
+ info("Changing to a US address to clear the error");
+ await selectPaymentDialogShippingAddressByCountry(frame, "US");
+
+ info("Tell merchant page to retry with a regionCode error string");
+ let retryPromise2 = SpecialPowers.spawn(
+ browser,
+ [
+ {
+ delayMs: 1000,
+ validationErrors: {
+ shippingAddress: {
+ regionCode: "Can only ship to California",
+ },
+ },
+ },
+ ],
+ PTU.ContentTasks.addRetryHandler
+ );
+
+ await loginAndCompletePayment(frame);
+
+ await spawnPaymentDialogTask(frame, async function checkRegionError() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ ({ request }) => {
+ return request.completeStatus === "";
+ },
+ "Wait for completeStatus from DOM update"
+ );
+
+ is(state.request.completeStatus, "", "Check completeStatus");
+ is(
+ state.request.paymentDetails.shippingAddressErrors.regionCode,
+ "Can only ship to California",
+ "Check regionCode error string in state"
+ );
+ ok(!state.changesPrevented, "Changes no longer prevented");
+ is(state.page.id, "payment-summary", "Check still on payment-summary");
+
+ ok(
+ content.document
+ .querySelector("#payment-summary")
+ .innerText.includes("Can only ship to California"),
+ "Check error visibility on summary page"
+ );
+ ok(
+ content.document.getElementById("pay").disabled,
+ "Pay button should be disabled until the field error is addressed"
+ );
+ });
+
+ info(
+ "Changing the shipping state to CA without changing selectedShippingAddress"
+ );
+ await navigateToAddShippingAddressPage(frame, {
+ addLinkSelector:
+ 'address-picker[selected-state-key="selectedShippingAddress"] .edit-link',
+ });
+ await fillInShippingAddressForm(frame, { "address-level1": "CA" });
+ await submitAddressForm(frame, null, { isEditing: true });
+
+ await loginAndCompletePayment(frame);
+
+ // We can only check the retry response after the closing as it only resolves upon complete.
+ let { retryException } = await retryPromise;
+ ok(
+ !retryException,
+ "Expect no exception to be thrown when calling retry()"
+ );
+
+ let { retryException2 } = await retryPromise2;
+ ok(
+ !retryException2,
+ "Expect no exception to be thrown when calling retry()"
+ );
+
+ // Add a handler to complete the payment above.
+ info("acknowledging the completion from the merchant page");
+ let result = await SpecialPowers.spawn(
+ browser,
+ [],
+ PTU.ContentTasks.addCompletionHandler
+ );
+
+ // Verify response has the expected properties
+ let expectedDetails = Object.assign(
+ {
+ "cc-security-code": "123",
+ },
+ PTU.BasicCards.JohnDoe
+ );
+
+ checkPaymentMethodDetailsMatchesCard(
+ result.response.details,
+ expectedDetails,
+ "Check response payment details"
+ );
+ checkPaymentAddressMatchesStorageAddress(
+ result.response.shippingAddress,
+ { ...PTU.Addresses.TimBL, ...{ "address-level1": "CA" } },
+ "Check response shipping address"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_retry_with_payerErrors() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let prefilledGuids = await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ options: PTU.Options.requestPayerNameEmailAndPhone,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ({ prefilledGuids: guids }) => {
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ guids.card2GUID
+ );
+ },
+ { prefilledGuids }
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "123",
+ }
+ );
+
+ info("clicking the button to try pay the 1st time");
+ await loginAndCompletePayment(frame);
+
+ let retryUpdatePromise = spawnPaymentDialogTask(
+ frame,
+ async function checkDialog() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ ({ request }) => {
+ return request.completeStatus === "processing";
+ },
+ "Wait for completeStatus from pay button click"
+ );
+
+ is(
+ state.request.completeStatus,
+ "processing",
+ "Check completeStatus is processing"
+ );
+
+ is(
+ state.request.paymentDetails.payerErrors.email,
+ undefined,
+ "Check email error isn't present"
+ );
+ ok(state.changesPrevented, "Changes prevented");
+
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ ({ request }) => {
+ return request.completeStatus === "";
+ },
+ "Wait for completeStatus from DOM update"
+ );
+
+ is(state.request.completeStatus, "", "Check completeStatus");
+ is(
+ state.request.paymentDetails.payerErrors.email,
+ "You must use your employee email address",
+ "Check email error string in state"
+ );
+ ok(!state.changesPrevented, "Changes no longer prevented");
+ is(
+ state.page.id,
+ "payment-summary",
+ "Check still on payment-summary"
+ );
+
+ ok(
+ content.document
+ .querySelector("#payment-summary")
+ .innerText.includes("You must use your employee email address"),
+ "Check error visibility on summary page"
+ );
+ ok(
+ content.document.getElementById("pay").disabled,
+ "Pay button should be disabled until the field error is addressed"
+ );
+ }
+ );
+
+ // Add a handler to retry the payment above.
+ info("Tell merchant page to retry with a country error string");
+ let retryPromise = SpecialPowers.spawn(
+ browser,
+ [
+ {
+ delayMs: 1000,
+ validationErrors: {
+ payer: {
+ email: "You must use your employee email address",
+ },
+ },
+ },
+ ],
+ PTU.ContentTasks.addRetryHandler
+ );
+
+ await retryUpdatePromise;
+
+ info("Changing to a different email address to clear the error");
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.selectPayerAddressByGuid,
+ prefilledGuids.address1GUID
+ );
+
+ info("Tell merchant page to retry with a phone error string");
+ let retryPromise2 = SpecialPowers.spawn(
+ browser,
+ [
+ {
+ delayMs: 1000,
+ validationErrors: {
+ payer: {
+ phone: "Your phone number isn't valid",
+ },
+ },
+ },
+ ],
+ PTU.ContentTasks.addRetryHandler
+ );
+
+ await loginAndCompletePayment(frame);
+
+ await spawnPaymentDialogTask(frame, async function checkRegionError() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ ({ request }) => {
+ return request.completeStatus === "";
+ },
+ "Wait for completeStatus from DOM update"
+ );
+
+ is(state.request.completeStatus, "", "Check completeStatus");
+ is(
+ state.request.paymentDetails.payerErrors.phone,
+ "Your phone number isn't valid",
+ "Check regionCode error string in state"
+ );
+ ok(!state.changesPrevented, "Changes no longer prevented");
+ is(state.page.id, "payment-summary", "Check still on payment-summary");
+
+ ok(
+ content.document
+ .querySelector("#payment-summary")
+ .innerText.includes("Your phone number isn't valid"),
+ "Check error visibility on summary page"
+ );
+ ok(
+ content.document.getElementById("pay").disabled,
+ "Pay button should be disabled until the field error is addressed"
+ );
+ });
+
+ info(
+ "Changing the payer phone to be valid without changing selectedPayerAddress"
+ );
+ await navigateToAddAddressPage(frame, {
+ addLinkSelector:
+ 'address-picker[selected-state-key="selectedPayerAddress"] .edit-link',
+ initialPageId: "payment-summary",
+ addressPageId: "payer-address-page",
+ });
+
+ let newPhoneNumber = "+16175555555";
+ await fillInPayerAddressForm(frame, { tel: newPhoneNumber });
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "payerdetailchange",
+ },
+ ],
+ PTU.ContentTasks.promisePaymentResponseEvent
+ );
+
+ await submitAddressForm(frame, null, { isEditing: true });
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "payerdetailchange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ await loginAndCompletePayment(frame);
+
+ // We can only check the retry response after the closing as it only resolves upon complete.
+ let { retryException } = await retryPromise;
+ ok(
+ !retryException,
+ "Expect no exception to be thrown when calling retry()"
+ );
+
+ let { retryException2 } = await retryPromise2;
+ ok(
+ !retryException2,
+ "Expect no exception to be thrown when calling retry()"
+ );
+
+ // Add a handler to complete the payment above.
+ info("acknowledging the completion from the merchant page");
+ let result = await SpecialPowers.spawn(
+ browser,
+ [],
+ PTU.ContentTasks.addCompletionHandler
+ );
+
+ // Verify response has the expected properties
+ let expectedDetails = Object.assign(
+ {
+ "cc-security-code": "123",
+ },
+ PTU.BasicCards.JohnDoe
+ );
+
+ checkPaymentMethodDetailsMatchesCard(
+ result.response.details,
+ expectedDetails,
+ "Check response payment details"
+ );
+ let {
+ "given-name": givenName,
+ "additional-name": additionalName,
+ "family-name": familyName,
+ email,
+ } = PTU.Addresses.TimBL;
+ is(
+ result.response.payerName,
+ `${givenName} ${additionalName} ${familyName}`,
+ "Check payer name"
+ );
+ is(result.response.payerEmail, email, "Check payer email");
+ is(result.response.payerPhone, newPhoneNumber, "Check payer phone");
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_retry_with_paymentMethodErrors() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let prefilledGuids = await setup();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ({ prefilledGuids: guids }) => {
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ guids.card1GUID
+ );
+ },
+ { prefilledGuids }
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "123",
+ }
+ );
+
+ info("clicking the button to try pay the 1st time");
+ await loginAndCompletePayment(frame);
+
+ let retryUpdatePromise = spawnPaymentDialogTask(
+ frame,
+ async function checkDialog() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ ({ request }) => {
+ return request.completeStatus === "processing";
+ },
+ "Wait for completeStatus from pay button click"
+ );
+
+ is(
+ state.request.completeStatus,
+ "processing",
+ "Check completeStatus is processing"
+ );
+
+ is(
+ state.request.paymentDetails.paymentMethodErrors,
+ null,
+ "Check no paymentMethod errors are present"
+ );
+ ok(state.changesPrevented, "Changes prevented");
+
+ state = await PTU.DialogContentUtils.waitForState(
+ content,
+ ({ request }) => {
+ return request.completeStatus === "";
+ },
+ "Wait for completeStatus from DOM update"
+ );
+
+ is(state.request.completeStatus, "", "Check completeStatus");
+ is(
+ state.request.paymentDetails.paymentMethodErrors.cardSecurityCode,
+ "Your CVV is incorrect",
+ "Check cardSecurityCode error string in state"
+ );
+
+ ok(!state.changesPrevented, "Changes no longer prevented");
+ is(
+ state.page.id,
+ "payment-summary",
+ "Check still on payment-summary"
+ );
+
+ todo(
+ content.document
+ .querySelector("#payment-summary")
+ .innerText.includes("Your CVV is incorrect"),
+ "Bug 1491815: Check error visibility on summary page"
+ );
+ todo(
+ content.document.getElementById("pay").disabled,
+ "Bug 1491815: Pay button should be disabled until the field error is addressed"
+ );
+ }
+ );
+
+ // Add a handler to retry the payment above.
+ info("Tell merchant page to retry with a cardSecurityCode error string");
+ let retryPromise = SpecialPowers.spawn(
+ browser,
+ [
+ {
+ delayMs: 1000,
+ validationErrors: {
+ paymentMethod: {
+ cardSecurityCode: "Your CVV is incorrect",
+ },
+ },
+ },
+ ],
+ PTU.ContentTasks.addRetryHandler
+ );
+
+ await retryUpdatePromise;
+
+ info("Changing to a different card to clear the error");
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.selectPaymentOptionByGuid,
+ prefilledGuids.card1GUID
+ );
+
+ info(
+ "Tell merchant page to retry with a billing postalCode error string"
+ );
+ let retryPromise2 = SpecialPowers.spawn(
+ browser,
+ [
+ {
+ delayMs: 1000,
+ validationErrors: {
+ paymentMethod: {
+ billingAddress: {
+ postalCode: "Your postal code isn't valid",
+ },
+ },
+ },
+ },
+ ],
+ PTU.ContentTasks.addRetryHandler
+ );
+
+ await loginAndCompletePayment(frame);
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkPostalCodeError() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let state = await PTU.DialogContentUtils.waitForState(
+ content,
+ ({ request }) => {
+ return request.completeStatus === "";
+ },
+ "Wait for completeStatus from DOM update"
+ );
+
+ is(state.request.completeStatus, "", "Check completeStatus");
+ is(
+ state.request.paymentDetails.paymentMethodErrors.billingAddress
+ .postalCode,
+ "Your postal code isn't valid",
+ "Check postalCode error string in state"
+ );
+ ok(!state.changesPrevented, "Changes no longer prevented");
+ is(
+ state.page.id,
+ "payment-summary",
+ "Check still on payment-summary"
+ );
+
+ todo(
+ content.document
+ .querySelector("#payment-summary")
+ .innerText.includes("Your postal code isn't valid"),
+ "Bug 1491815: Check error visibility on summary page"
+ );
+ todo(
+ content.document.getElementById("pay").disabled,
+ "Bug 1491815: Pay button should be disabled until the field error is addressed"
+ );
+ }
+ );
+
+ info(
+ "Changing the billingAddress postalCode to be valid without changing selectedPaymentCard"
+ );
+
+ await navigateToAddCardPage(frame, {
+ addLinkSelector: "payment-method-picker .edit-link",
+ });
+
+ await navigateToAddAddressPage(frame, {
+ addLinkSelector: ".billingAddressRow .edit-link",
+ initialPageId: "basic-card-page",
+ addressPageId: "billing-address-page",
+ });
+
+ let newPostalCode = "90210";
+ await fillInBillingAddressForm(frame, { "postal-code": newPostalCode });
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "paymentmethodchange",
+ },
+ ],
+ PTU.ContentTasks.promisePaymentResponseEvent
+ );
+
+ await submitAddressForm(frame, null, {
+ isEditing: true,
+ nextPageId: "basic-card-page",
+ });
+
+ await spawnPaymentDialogTask(frame, async function checkErrorsCleared() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.request.paymentDetails.paymentMethodErrors == null;
+ },
+ "Check no paymentMethod errors are present"
+ );
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ await spawnPaymentDialogTask(frame, async function checkErrorsCleared() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.request.paymentDetails.paymentMethodErrors == null;
+ },
+ "Check no card errors are present after save"
+ );
+ });
+
+ // TODO: Add an `await` here after bug 1477113.
+ SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "paymentmethodchange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ await loginAndCompletePayment(frame);
+
+ // We can only check the retry response after the closing as it only resolves upon complete.
+ let { retryException } = await retryPromise;
+ ok(
+ !retryException,
+ "Expect no exception to be thrown when calling retry()"
+ );
+
+ let { retryException2 } = await retryPromise2;
+ ok(
+ !retryException2,
+ "Expect no exception to be thrown when calling retry()"
+ );
+
+ // Add a handler to complete the payment above.
+ info("acknowledging the completion from the merchant page");
+ let result = await SpecialPowers.spawn(
+ browser,
+ [],
+ PTU.ContentTasks.addCompletionHandler
+ );
+
+ // Verify response has the expected properties
+ let expectedDetails = Object.assign(
+ {
+ "cc-security-code": "123",
+ },
+ PTU.BasicCards.JaneMasterCard
+ );
+
+ let expectedBillingAddress = Object.assign({}, PTU.Addresses.TimBL, {
+ "postal-code": newPostalCode,
+ });
+
+ checkPaymentMethodDetailsMatchesCard(
+ result.response.details,
+ expectedDetails,
+ "Check response payment details"
+ );
+ checkPaymentAddressMatchesStorageAddress(
+ result.response.details.billingAddress,
+ expectedBillingAddress,
+ "Check response billing address"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
diff --git a/browser/components/payments/test/browser/browser_shippingaddresschange_error.js b/browser/components/payments/test/browser/browser_shippingaddresschange_error.js
new file mode 100644
index 0000000000..803296eea6
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_shippingaddresschange_error.js
@@ -0,0 +1,446 @@
+/* eslint-disable no-shadow */
+
+"use strict";
+
+add_task(addSampleAddressesAndBasicCard);
+
+add_task(async function test_show_error_on_addresschange() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ info("setting up the event handler for shippingoptionchange");
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingoptionchange",
+ details: Object.assign(
+ {},
+ PTU.Details.genericShippingError,
+ PTU.Details.noShippingOptions,
+ PTU.Details.total2USD
+ ),
+ },
+ ],
+ PTU.ContentTasks.updateWith
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.selectShippingOptionById,
+ "1"
+ );
+
+ info("awaiting the shippingoptionchange event");
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingoptionchange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ expectedText => {
+ let errorText = content.document.querySelector("header .page-error");
+ is(
+ errorText.textContent,
+ expectedText,
+ "Error text should be on dialog"
+ );
+ ok(content.isVisible(errorText), "Error text should be visible");
+ },
+ PTU.Details.genericShippingError.error
+ );
+
+ info("setting up the event handler for shippingaddresschange");
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ details: Object.assign(
+ {},
+ PTU.Details.noError,
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ },
+ ],
+ PTU.ContentTasks.updateWith
+ );
+
+ await selectPaymentDialogShippingAddressByCountry(frame, "DE");
+
+ info("awaiting the shippingaddresschange event");
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ await spawnPaymentDialogTask(frame, () => {
+ let errorText = content.document.querySelector("header .page-error");
+ is(errorText.textContent, "", "Error text should not be on dialog");
+ ok(content.isHidden(errorText), "Error text should not be visible");
+ });
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_show_field_specific_error_on_addresschange() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ info("setting up the event handler for shippingaddresschange");
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ details: Object.assign(
+ {},
+ PTU.Details.fieldSpecificErrors,
+ PTU.Details.noShippingOptions,
+ PTU.Details.total2USD
+ ),
+ },
+ ],
+ PTU.ContentTasks.updateWith
+ );
+
+ spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.selectShippingAddressByCountry,
+ "DE"
+ );
+
+ info("awaiting the shippingaddresschange event");
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+
+ await spawnPaymentDialogTask(frame, async () => {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return Object.keys(
+ state.request.paymentDetails.shippingAddressErrors
+ ).length;
+ },
+ "Check that there are shippingAddressErrors"
+ );
+
+ is(
+ content.document.querySelector("header .page-error").textContent,
+ PTU.Details.fieldSpecificErrors.error,
+ "Error text should be present on dialog"
+ );
+
+ info("click the Edit link");
+ content.document
+ .querySelector("address-picker.shipping-related .edit-link")
+ .click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "shipping-address-page" &&
+ state["shipping-address-page"].guid
+ );
+ },
+ "Check edit page state"
+ );
+
+ // check errors and make corrections
+ let addressForm = content.document.querySelector(
+ "#shipping-address-page"
+ );
+ let { shippingAddressErrors } = PTU.Details.fieldSpecificErrors;
+ is(
+ addressForm.querySelectorAll(".error-text:not(:empty)").length,
+ Object.keys(shippingAddressErrors).length - 1,
+ "Each error should be presented, but only one of region and regionCode are displayed"
+ );
+ let errorFieldMap = Cu.waiveXrays(addressForm)._errorFieldMap;
+ for (let [errorName, errorValue] of Object.entries(
+ shippingAddressErrors
+ )) {
+ if (errorName == "region" || errorName == "regionCode") {
+ errorValue = shippingAddressErrors.regionCode;
+ }
+ let fieldSelector = errorFieldMap[errorName];
+ let containerSelector = fieldSelector + "-container";
+ let container = addressForm.querySelector(containerSelector);
+ try {
+ is(
+ container.querySelector(".error-text").textContent,
+ errorValue,
+ "Field specific error should be associated with " + errorName
+ );
+ } catch (ex) {
+ ok(
+ false,
+ `no container for ${errorName}. selector= ${containerSelector}`
+ );
+ }
+ try {
+ let field = addressForm.querySelector(fieldSelector);
+ let oldValue = field.value;
+ if (field.localName == "select") {
+ // Flip between empty and the selected entry so country fields won't change.
+ content.fillField(field, "");
+ content.fillField(field, oldValue);
+ } else {
+ content.fillField(
+ field,
+ field.value
+ .split("")
+ .reverse()
+ .join("")
+ );
+ }
+ } catch (ex) {
+ ok(
+ false,
+ `no field found for ${errorName}. selector= ${fieldSelector}`
+ );
+ }
+ }
+ });
+
+ info(
+ "setting up the event handler for a 2nd shippingaddresschange with a different error"
+ );
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ details: Object.assign(
+ {},
+ {
+ shippingAddressErrors: {
+ phone: "Invalid phone number",
+ },
+ },
+ PTU.Details.noShippingOptions,
+ PTU.Details.total2USD
+ ),
+ },
+ ],
+ PTU.ContentTasks.updateWith
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ await spawnPaymentDialogTask(frame, async function checkForNewErrors() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "payment-summary" &&
+ state.request.paymentDetails.shippingAddressErrors.phone ==
+ "Invalid phone number"
+ );
+ },
+ "Check the new error is in state"
+ );
+
+ ok(
+ content.document
+ .querySelector("#payment-summary")
+ .innerText.includes("Invalid phone number"),
+ "Check error visibility on summary page"
+ );
+ ok(
+ content.document.getElementById("pay").disabled,
+ "Pay button should be disabled until the field error is addressed"
+ );
+ });
+
+ await navigateToAddShippingAddressPage(frame, {
+ addLinkSelector:
+ 'address-picker[selected-state-key="selectedShippingAddress"] .edit-link',
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ async function checkForNewErrorOnEdit() {
+ let addressForm = content.document.querySelector(
+ "#shipping-address-page"
+ );
+ is(
+ addressForm.querySelectorAll(".error-text:not(:empty)").length,
+ 1,
+ "Check one error shown"
+ );
+ }
+ );
+
+ await fillInShippingAddressForm(frame, {
+ tel: PTU.Addresses.TimBL2.tel,
+ });
+
+ info("setup updateWith to clear errors");
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingaddresschange",
+ details: Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+ ),
+ },
+ ],
+ PTU.ContentTasks.updateWith
+ );
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ await spawnPaymentDialogTask(frame, async function fixLastError() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "Check we're back on summary view"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return !Object.keys(
+ state.request.paymentDetails.shippingAddressErrors
+ ).length;
+ },
+ "Check that there are no more shippingAddressErrors"
+ );
+
+ is(
+ content.document.querySelector("header .page-error").textContent,
+ "",
+ "Error text should not be present on dialog"
+ );
+
+ info("click the Edit link again");
+ content.document.querySelector("address-picker .edit-link").click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return (
+ state.page.id == "shipping-address-page" &&
+ state["shipping-address-page"].guid
+ );
+ },
+ "Check edit page state"
+ );
+
+ let addressForm = content.document.querySelector(
+ "#shipping-address-page"
+ );
+ // check no errors present
+ let errorTextSpans = addressForm.querySelectorAll(
+ ".error-text:not(:empty)"
+ );
+ for (let errorTextSpan of errorTextSpans) {
+ is(
+ errorTextSpan.textContent,
+ "",
+ "No errors should be present on the field"
+ );
+ }
+
+ info("click the Back button");
+ addressForm.querySelector(".back-button").click();
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "payment-summary";
+ },
+ "Check we're back on summary view"
+ );
+ });
+
+ info("clicking cancel");
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
diff --git a/browser/components/payments/test/browser/browser_show_dialog.js b/browser/components/payments/test/browser/browser_show_dialog.js
new file mode 100644
index 0000000000..f3492cfdd9
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_show_dialog.js
@@ -0,0 +1,400 @@
+"use strict";
+
+const methodData = [PTU.MethodData.basicCard];
+const details = Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+);
+
+add_task(async function test_show_abort_dialog() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win } = await setupPaymentDialog(browser, {
+ methodData,
+ details,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ // abort the payment request
+ SpecialPowers.spawn(browser, [], async () => content.rq.abort());
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_show_manualAbort_dialog() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData,
+ details,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_show_completePayment() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let { address1GUID, card1GUID } = await addSampleAddressesAndBasicCard();
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "update"
+ );
+ info("associating the card with the billing address");
+ await formAutofillStorage.creditCards.update(
+ card1GUID,
+ {
+ billingAddressGUID: address1GUID,
+ },
+ true
+ );
+ await onChanged;
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData,
+ details,
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ info("select the shipping address");
+ await selectPaymentDialogShippingAddressByCountry(frame, "US");
+
+ await spawnPaymentDialogTask(
+ frame,
+ async ({ card1GUID: cardGuid }) => {
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ cardGuid
+ );
+ },
+ { card1GUID }
+ );
+
+ info("entering CSC");
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "999",
+ }
+ );
+ info("clicking pay");
+ await loginAndCompletePayment(frame);
+
+ // Add a handler to complete the payment above.
+ info("acknowledging the completion from the merchant page");
+ let result = await SpecialPowers.spawn(
+ browser,
+ [],
+ PTU.ContentTasks.addCompletionHandler
+ );
+
+ let { shippingAddress } = result.response;
+ checkPaymentAddressMatchesStorageAddress(
+ shippingAddress,
+ PTU.Addresses.TimBL,
+ "Shipping"
+ );
+
+ is(result.response.methodName, "basic-card", "Check methodName");
+ let { methodDetails } = result;
+ checkPaymentMethodDetailsMatchesCard(
+ methodDetails,
+ PTU.BasicCards.JohnDoe,
+ "Payment method"
+ );
+ is(methodDetails.cardSecurityCode, "999", "Check cardSecurityCode");
+ is(
+ typeof methodDetails.methodName,
+ "undefined",
+ "Check methodName wasn't included"
+ );
+
+ checkPaymentAddressMatchesStorageAddress(
+ methodDetails.billingAddress,
+ PTU.Addresses.TimBL,
+ "Billing address"
+ );
+
+ is(result.response.shippingOption, "2", "Check shipping option");
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_show_completePayment2() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData,
+ details,
+ options: PTU.Options.requestShippingOption,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingoptionchange",
+ },
+ ],
+ PTU.ContentTasks.promisePaymentRequestEvent
+ );
+
+ info(
+ "changing shipping option to '1' from default selected option of '2'"
+ );
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.selectShippingOptionById,
+ "1"
+ );
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ eventName: "shippingoptionchange",
+ },
+ ],
+ PTU.ContentTasks.awaitPaymentEventPromise
+ );
+ info("got shippingoptionchange event");
+
+ info("select the shipping address");
+ await selectPaymentDialogShippingAddressByCountry(frame, "US");
+
+ await spawnPaymentDialogTask(frame, async () => {
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox.options[0].value
+ );
+ });
+
+ info("entering CSC");
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "123",
+ }
+ );
+
+ info("clicking pay");
+ await loginAndCompletePayment(frame);
+
+ // Add a handler to complete the payment above.
+ info("acknowledging the completion from the merchant page");
+ let result = await SpecialPowers.spawn(
+ browser,
+ [],
+ PTU.ContentTasks.addCompletionHandler
+ );
+
+ is(result.response.shippingOption, "1", "Check shipping option");
+
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_localized() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData,
+ details,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ await spawnPaymentDialogTask(frame, async function check_l10n() {
+ await ContentTaskUtils.waitForCondition(() => {
+ let telephoneLabel = content.document.querySelector(
+ "#tel-container > .label-text"
+ );
+ return telephoneLabel && telephoneLabel.textContent.includes("Phone");
+ }, "Check that the telephone number label is localized");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ let ccNumberField = content.document.querySelector("#cc-number");
+ if (!ccNumberField) {
+ return false;
+ }
+ let ccNumberLabel = ccNumberField.parentElement.querySelector(
+ ".label-text"
+ );
+ return ccNumberLabel.textContent.includes("Number");
+ }, "Check that the cc-number label is localized");
+
+ const L10N_ATTRIBUTE_SELECTOR =
+ "[data-localization], [data-localization-region]";
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ content.document.querySelectorAll(L10N_ATTRIBUTE_SELECTOR)
+ .length === 0
+ );
+ }, "Check that there are no unlocalized strings");
+ });
+
+ // abort the payment request
+ SpecialPowers.spawn(browser, [], async () => content.rq.abort());
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
+
+add_task(async function test_supportedNetworks() {
+ await setupFormAutofillStorage();
+ await cleanupFormAutofillStorage();
+
+ let address1GUID = await addAddressRecord(PTU.Addresses.TimBL);
+ let visaCardGUID = await addCardRecord(
+ Object.assign({}, PTU.BasicCards.JohnDoe, {
+ billingAddressGUID: address1GUID,
+ })
+ );
+ let masterCardGUID = await addCardRecord(
+ Object.assign({}, PTU.BasicCards.JaneMasterCard, {
+ billingAddressGUID: address1GUID,
+ })
+ );
+
+ let cardMethod = {
+ supportedMethods: "basic-card",
+ data: {
+ supportedNetworks: ["visa"],
+ },
+ };
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData: [cardMethod],
+ details,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ info("entering CSC");
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.setSecurityCode,
+ {
+ securityCode: "789",
+ }
+ );
+
+ await spawnPaymentDialogTask(frame, () => {
+ let acceptedCards = content.document.querySelector("accepted-cards");
+ ok(
+ acceptedCards && !content.isHidden(acceptedCards),
+ "accepted-cards element is present and visible"
+ );
+ is(
+ Cu.waiveXrays(acceptedCards).acceptedItems.length,
+ 1,
+ "accepted-cards element has 1 item"
+ );
+ });
+
+ info("select the mastercard using guid: " + masterCardGUID);
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.selectPaymentOptionByGuid,
+ masterCardGUID
+ );
+
+ info("spawn task to check pay button with mastercard selected");
+ await spawnPaymentDialogTask(frame, async () => {
+ ok(
+ content.document.getElementById("pay").disabled,
+ "pay button should be disabled"
+ );
+ });
+
+ info("select the visa using guid: " + visaCardGUID);
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.selectPaymentOptionByGuid,
+ visaCardGUID
+ );
+
+ info("spawn task to check pay button");
+ await spawnPaymentDialogTask(frame, async () => {
+ ok(
+ !content.document.getElementById("pay").disabled,
+ "pay button should not be disabled"
+ );
+ });
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+});
diff --git a/browser/components/payments/test/browser/browser_tab_modal.js b/browser/components/payments/test/browser/browser_tab_modal.js
new file mode 100644
index 0000000000..54a25df5e8
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_tab_modal.js
@@ -0,0 +1,300 @@
+"use strict";
+
+const methodData = [PTU.MethodData.basicCard];
+const details = Object.assign(
+ {},
+ PTU.Details.twoShippingOptions,
+ PTU.Details.total2USD
+);
+
+async function checkTabModal(browser, win, msg) {
+ info(`checkTabModal: ${msg}`);
+ let doc = browser.ownerDocument;
+ await TestUtils.waitForCondition(() => {
+ return !doc.querySelector(".paymentDialogContainer").hidden;
+ }, "Waiting for container to be visible after the dialog's ready");
+ is(
+ doc.querySelectorAll(".paymentDialogContainer").length,
+ 1,
+ "Only 1 paymentDialogContainer"
+ );
+ ok(!EventUtils.isHidden(win.frameElement), "Frame should be visible");
+
+ let { bottom: toolboxBottom } = doc
+ .getElementById("navigator-toolbox")
+ .getBoundingClientRect();
+
+ let { x, y } = win.frameElement.getBoundingClientRect();
+ ok(y > 0, "Frame should have y > 0");
+ // Inset by 10px since the corner point doesn't return the frame due to the
+ // border-radius.
+ is(
+ doc.elementFromPoint(x + 10, y + 10),
+ win.frameElement,
+ "Check .paymentDialogContainerFrame is visible"
+ );
+
+ info("Click to the left of the dialog over the content area");
+ isnot(
+ doc.elementFromPoint(x - 10, y + 50),
+ browser,
+ "Check clicks on the merchant content area don't go to the browser"
+ );
+ is(
+ doc.elementFromPoint(x - 10, y + 50),
+ doc.querySelector(".paymentDialogBackground"),
+ "Check clicks on the merchant content area go to the payment dialog background"
+ );
+
+ ok(
+ y < toolboxBottom - 2,
+ "Dialog should overlap the toolbox by at least 2px"
+ );
+
+ ok(
+ browser.hasAttribute("tabmodalPromptShowing"),
+ "Check browser has @tabmodalPromptShowing"
+ );
+
+ return {
+ x,
+ y,
+ };
+}
+
+add_task(async function test_tab_modal() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async browser => {
+ let { win, frame } = await setupPaymentDialog(browser, {
+ methodData,
+ details,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ let { x, y } = await checkTabModal(browser, win, "initial dialog");
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ async newBrowser => {
+ let { x: x2, y: y2 } = win.frameElement.getBoundingClientRect();
+ is(x2, x, "Check x-coordinate is the same");
+ is(y2, y, "Check y-coordinate is the same");
+ isnot(
+ document.elementFromPoint(x + 10, y + 10),
+ win.frameElement,
+ "Check .paymentDialogContainerFrame is hidden"
+ );
+ ok(
+ !newBrowser.hasAttribute("tabmodalPromptShowing"),
+ "Check second browser doesn't have @tabmodalPromptShowing"
+ );
+ }
+ );
+
+ let { x: x3, y: y3 } = await checkTabModal(
+ browser,
+ win,
+ "after tab switch back"
+ );
+ is(x3, x, "Check x-coordinate is the same again");
+ is(y3, y, "Check y-coordinate is the same again");
+
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () => !browser.hasAttribute("tabmodalPromptShowing"),
+ "Check @tabmodalPromptShowing was removed"
+ );
+ }
+ );
+});
+
+add_task(async function test_detachToNewWindow() {
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: BLANK_PAGE_URL,
+ });
+ let browser = tab.linkedBrowser;
+
+ let { frame, requestId } = await setupPaymentDialog(browser, {
+ methodData,
+ details,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
+
+ is(
+ Object.values(frame.paymentDialogWrapper.temporaryStore.addresses.getAll())
+ .length,
+ 0,
+ "Check initial temp. address store"
+ );
+ is(
+ Object.values(
+ frame.paymentDialogWrapper.temporaryStore.creditCards.getAll()
+ ).length,
+ 0,
+ "Check initial temp. card store"
+ );
+
+ info("Create some temp. records so we can later check if they are preserved");
+ let address1 = { ...PTU.Addresses.Temp };
+ let card1 = { ...PTU.BasicCards.JaneMasterCard, ...{ "cc-csc": "123" } };
+
+ await fillInBillingAddressForm(frame, address1, {
+ setPersistCheckedValue: false,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ await spawnPaymentDialogTask(frame, async function waitForPageChange() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "basic-card-page";
+ },
+ "Wait for basic-card-page"
+ );
+ });
+
+ await fillInCardForm(frame, card1, {
+ checkboxSelector: "basic-card-form .persist-checkbox",
+ setPersistCheckedValue: false,
+ });
+
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.clickPrimaryButton
+ );
+
+ let { temporaryStore } = frame.paymentDialogWrapper;
+ TestUtils.waitForCondition(() => {
+ return Object.values(temporaryStore.addresses.getAll()).length == 1;
+ }, "Check address store");
+ TestUtils.waitForCondition(() => {
+ return Object.values(temporaryStore.creditCards.getAll()).length == 1;
+ }, "Check card store");
+
+ let windowLoadedPromise = BrowserTestUtils.waitForNewWindow();
+ let newWin = gBrowser.replaceTabWithWindow(tab);
+ await windowLoadedPromise;
+
+ info("tab was detached");
+ let newBrowser = newWin.gBrowser.selectedBrowser;
+ ok(newBrowser, "Found new <browser>");
+
+ let widget = await TestUtils.waitForCondition(async () =>
+ getPaymentWidget(requestId)
+ );
+ await checkTabModal(newBrowser, widget, "after detach");
+
+ let state = await spawnPaymentDialogTask(
+ widget.frameElement,
+ async function checkAfterDetach() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ return PTU.DialogContentUtils.getCurrentState(content);
+ }
+ );
+
+ is(
+ Object.values(state.tempAddresses).length,
+ 1,
+ "Check still 1 temp. address in state"
+ );
+ is(
+ Object.values(state.tempBasicCards).length,
+ 1,
+ "Check still 1 temp. basic card in state"
+ );
+
+ temporaryStore = widget.frameElement.paymentDialogWrapper.temporaryStore;
+ is(
+ Object.values(temporaryStore.addresses.getAll()).length,
+ 1,
+ "Check address store in wrapper"
+ );
+ is(
+ Object.values(temporaryStore.creditCards.getAll()).length,
+ 1,
+ "Check card store in wrapper"
+ );
+
+ info(
+ "Check that the message manager and formautofill-storage-changed observer are connected"
+ );
+ is(Object.values(state.savedAddresses).length, 0, "Check 0 saved addresses");
+ await addAddressRecord(PTU.Addresses.TimBL2);
+ await spawnPaymentDialogTask(
+ widget.frameElement,
+ async function waitForSavedAddress() {
+ let { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ await PTU.DialogContentUtils.waitForState(
+ content,
+ function checkSavedAddresses(s) {
+ return Object.values(s.savedAddresses).length == 1;
+ },
+ "Check 1 saved address in state"
+ );
+ }
+ );
+
+ info(
+ "re-attach the tab back in the original window to test the event listeners were added"
+ );
+
+ let tab3 = gBrowser.adoptTab(newWin.gBrowser.selectedTab, 1, true);
+ widget = await TestUtils.waitForCondition(async () =>
+ getPaymentWidget(requestId)
+ );
+ is(
+ widget.frameElement.ownerGlobal,
+ window,
+ "Check widget is back in first window"
+ );
+ await checkTabModal(tab3.linkedBrowser, widget, "after re-attaching");
+
+ temporaryStore = widget.frameElement.paymentDialogWrapper.temporaryStore;
+ is(
+ Object.values(temporaryStore.addresses.getAll()).length,
+ 1,
+ "Check temp addresses in wrapper"
+ );
+ is(
+ Object.values(temporaryStore.creditCards.getAll()).length,
+ 1,
+ "Check temp cards in wrapper"
+ );
+
+ spawnPaymentDialogTask(
+ widget.frameElement,
+ PTU.DialogContentTasks.manuallyClickCancel
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => widget.closed,
+ "dialog should be closed"
+ );
+ await BrowserTestUtils.removeTab(tab3);
+});
diff --git a/browser/components/payments/test/browser/browser_total.js b/browser/components/payments/test/browser/browser_total.js
new file mode 100644
index 0000000000..0f02d65c90
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_total.js
@@ -0,0 +1,94 @@
+"use strict";
+
+add_task(async function test_total() {
+ const testTask = ({ methodData, details }) => {
+ is(
+ content.document.querySelector("#total > currency-amount").textContent,
+ "$60.00 USD",
+ "Check total currency amount"
+ );
+ };
+ const args = {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ };
+ await spawnInDialogForMerchantTask(
+ PTU.ContentTasks.createAndShowRequest,
+ testTask,
+ args
+ );
+});
+
+add_task(async function test_modifier_with_no_method_selected() {
+ const testTask = async ({ methodData, details }) => {
+ // There are no payment methods installed/setup so we expect the original (unmodified) total.
+ is(
+ content.document.querySelector("#total > currency-amount").textContent,
+ "$2.00 USD",
+ "Check unmodified total currency amount"
+ );
+ };
+ const args = {
+ methodData: [PTU.MethodData.bobPay, PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.bobPayPaymentModifier,
+ PTU.Details.total2USD
+ ),
+ };
+ await spawnInDialogForMerchantTask(
+ PTU.ContentTasks.createAndShowRequest,
+ testTask,
+ args
+ );
+});
+
+add_task(async function test_modifier_with_no_method_selected() {
+ info("adding a basic-card");
+ let prefilledGuids = await addSampleAddressesAndBasicCard();
+
+ const testTask = async ({ methodData, details, prefilledGuids: guids }) => {
+ is(
+ content.document.querySelector("#total > currency-amount").textContent,
+ "$2.00 USD",
+ "Check total currency amount before selecting the credit card"
+ );
+
+ // Select the (only) payment method.
+ let paymentMethodPicker = content.document.querySelector(
+ "payment-method-picker"
+ );
+ content.fillField(
+ Cu.waiveXrays(paymentMethodPicker).dropdown.popupBox,
+ guids.card1GUID
+ );
+
+ await ContentTaskUtils.waitForCondition(() => {
+ let currencyAmount = content.document.querySelector(
+ "#total > currency-amount"
+ );
+ return currencyAmount.textContent == "$2.50 USD";
+ }, "Wait for modified total to update");
+
+ is(
+ content.document.querySelector("#total > currency-amount").textContent,
+ "$2.50 USD",
+ "Check modified total currency amount"
+ );
+ };
+ const args = {
+ methodData: [PTU.MethodData.bobPay, PTU.MethodData.basicCard],
+ details: Object.assign(
+ {},
+ PTU.Details.bobPayPaymentModifier,
+ PTU.Details.total2USD
+ ),
+ prefilledGuids,
+ };
+ await spawnInDialogForMerchantTask(
+ PTU.ContentTasks.createAndShowRequest,
+ testTask,
+ args
+ );
+ await cleanupFormAutofillStorage();
+});
diff --git a/browser/components/payments/test/browser/head.js b/browser/components/payments/test/browser/head.js
new file mode 100644
index 0000000000..b32776d5a4
--- /dev/null
+++ b/browser/components/payments/test/browser/head.js
@@ -0,0 +1,880 @@
+"use strict";
+
+/* eslint
+ "no-unused-vars": ["error", {
+ vars: "local",
+ args: "none",
+ }],
+*/
+
+const BLANK_PAGE_PATH =
+ "/browser/browser/components/payments/test/browser/blank_page.html";
+const BLANK_PAGE_URL = "https://example.com" + BLANK_PAGE_PATH;
+const RESPONSE_TIMEOUT_PREF = "dom.payments.response.timeout";
+const SAVE_CREDITCARD_DEFAULT_PREF = "dom.payments.defaults.saveCreditCard";
+const SAVE_ADDRESS_DEFAULT_PREF = "dom.payments.defaults.saveAddress";
+
+const paymentSrv = Cc[
+ "@mozilla.org/dom/payments/payment-request-service;1"
+].getService(Ci.nsIPaymentRequestService);
+const paymentUISrv = Cc[
+ "@mozilla.org/dom/payments/payment-ui-service;1"
+].getService(Ci.nsIPaymentUIService).wrappedJSObject;
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { formAutofillStorage } = ChromeUtils.import(
+ "resource://formautofill/FormAutofillStorage.jsm"
+);
+const { OSKeyStoreTestUtils } = ChromeUtils.import(
+ "resource://testing-common/OSKeyStoreTestUtils.jsm"
+);
+const { PaymentTestUtils: PTU } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+);
+var { BrowserWindowTracker } = ChromeUtils.import(
+ "resource:///modules/BrowserWindowTracker.jsm"
+);
+var { CreditCard } = ChromeUtils.import(
+ "resource://gre/modules/CreditCard.jsm"
+);
+
+function getPaymentRequests() {
+ return Array.from(paymentSrv.enumerate());
+}
+
+/**
+ * Return the container (e.g. dialog or overlay) that the payment request contents are shown in.
+ * This abstracts away the details of the widget used so that this can more easily transition to
+ * another kind of dialog/overlay.
+ * @param {string} requestId
+ * @returns {Promise}
+ */
+async function getPaymentWidget(requestId) {
+ return BrowserTestUtils.waitForCondition(() => {
+ let { dialogContainer } = paymentUISrv.findDialog(requestId);
+ if (!dialogContainer) {
+ return false;
+ }
+ let paymentFrame = dialogContainer.querySelector(
+ ".paymentDialogContainerFrame"
+ );
+ if (!paymentFrame) {
+ return false;
+ }
+ return {
+ get closed() {
+ return !paymentFrame.isConnected;
+ },
+ frameElement: paymentFrame,
+ };
+ }, "payment dialog should be opened");
+}
+
+async function getPaymentFrame(widget) {
+ return widget.frameElement;
+}
+
+function waitForMessageFromWidget(messageType, widget = null) {
+ info("waitForMessageFromWidget: " + messageType);
+ return new Promise(resolve => {
+ Services.mm.addMessageListener(
+ "paymentContentToChrome",
+ function onMessage({ data, target }) {
+ if (data.messageType != messageType) {
+ return;
+ }
+ if (widget && widget != target) {
+ return;
+ }
+ resolve();
+ info(`Got ${messageType} from widget`);
+ Services.mm.removeMessageListener("paymentContentToChrome", onMessage);
+ }
+ );
+ });
+}
+
+async function waitForWidgetReady(widget = null) {
+ return waitForMessageFromWidget("paymentDialogReady", widget);
+}
+
+function spawnPaymentDialogTask(paymentDialogFrame, taskFn, args = null) {
+ return SpecialPowers.spawn(paymentDialogFrame.frameLoader, [args], taskFn);
+}
+
+async function withMerchantTab(
+ { browser = gBrowser, url = BLANK_PAGE_URL } = {
+ browser: gBrowser,
+ url: BLANK_PAGE_URL,
+ },
+ taskFn
+) {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser: browser,
+ url,
+ },
+ taskFn
+ );
+
+ paymentSrv.cleanup(); // Temporary measure until bug 1408234 is fixed.
+
+ await new Promise(resolve => {
+ SpecialPowers.exactGC(resolve);
+ });
+}
+
+async function withNewTabInPrivateWindow(args = {}, taskFn) {
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let tabArgs = Object.assign(args, {
+ browser: privateWin.gBrowser,
+ });
+ await withMerchantTab(tabArgs, taskFn);
+ await BrowserTestUtils.closeWindow(privateWin);
+}
+
+async function addAddressRecord(address) {
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "add"
+ );
+ let guid = await formAutofillStorage.addresses.add(address);
+ await onChanged;
+ return guid;
+}
+
+async function addCardRecord(card) {
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "add"
+ );
+ let guid = await formAutofillStorage.creditCards.add(card);
+ await onChanged;
+ return guid;
+}
+
+/**
+ * Add address and creditCard records to the formautofill store
+ *
+ * @param {array=} addresses - The addresses to add to the formautofill address store
+ * @param {array=} cards - The cards to add to the formautofill creditCards store
+ * @returns {Promise}
+ */
+async function addSampleAddressesAndBasicCard(
+ addresses = [PTU.Addresses.TimBL, PTU.Addresses.TimBL2],
+ cards = [PTU.BasicCards.JohnDoe]
+) {
+ let guids = {};
+
+ for (let i = 0; i < addresses.length; i++) {
+ guids[`address${i + 1}GUID`] = await addAddressRecord(addresses[i]);
+ }
+
+ for (let i = 0; i < cards.length; i++) {
+ guids[`card${i + 1}GUID`] = await addCardRecord(cards[i]);
+ }
+
+ return guids;
+}
+
+/**
+ * Checks that an address from autofill storage matches a Payment Request PaymentAddress.
+ * @param {PaymentAddress} paymentAddress
+ * @param {object} storageAddress
+ * @param {string} msg to describe the check
+ */
+function checkPaymentAddressMatchesStorageAddress(
+ paymentAddress,
+ storageAddress,
+ msg
+) {
+ info(msg);
+ let addressLines = storageAddress["street-address"].split("\n");
+ is(
+ paymentAddress.addressLine[0],
+ addressLines[0],
+ "Address line 1 should match"
+ );
+ is(
+ paymentAddress.addressLine[1],
+ addressLines[1],
+ "Address line 2 should match"
+ );
+ is(paymentAddress.country, storageAddress.country, "Country should match");
+ is(
+ paymentAddress.region,
+ storageAddress["address-level1"] || "",
+ "Region should match"
+ );
+ is(
+ paymentAddress.city,
+ storageAddress["address-level2"],
+ "City should match"
+ );
+ is(
+ paymentAddress.postalCode,
+ storageAddress["postal-code"],
+ "Zip code should match"
+ );
+ is(
+ paymentAddress.organization,
+ storageAddress.organization,
+ "Org should match"
+ );
+ is(
+ paymentAddress.recipient,
+ `${storageAddress["given-name"]} ${storageAddress["additional-name"]} ` +
+ `${storageAddress["family-name"]}`,
+ "Recipient name should match"
+ );
+ is(paymentAddress.phone, storageAddress.tel, "Phone should match");
+}
+
+/**
+ * Checks that a card from autofill storage matches a Payment Request MethodDetails response.
+ * @param {MethodDetails} methodDetails
+ * @param {object} card
+ * @param {string} msg to describe the check
+ */
+function checkPaymentMethodDetailsMatchesCard(methodDetails, card, msg) {
+ info(msg);
+ // The card expiry month should be a zero-padded two-digit string.
+ let cardExpiryMonth = card["cc-exp-month"].toString().padStart(2, "0");
+ is(methodDetails.cardholderName, card["cc-name"], "Check cardholderName");
+ is(methodDetails.cardNumber, card["cc-number"], "Check cardNumber");
+ is(methodDetails.expiryMonth, cardExpiryMonth, "Check expiryMonth");
+ is(methodDetails.expiryYear, card["cc-exp-year"], "Check expiryYear");
+}
+
+/**
+ * Create a PaymentRequest object with the given parameters, then
+ * run the given merchantTaskFn.
+ *
+ * @param {Object} browser
+ * @param {Object} options
+ * @param {Object} options.methodData
+ * @param {Object} options.details
+ * @param {Object} options.options
+ * @param {Function} options.merchantTaskFn
+ * @returns {Object} References to the window, requestId, and frame
+ */
+async function setupPaymentDialog(
+ browser,
+ { methodData, details, options, merchantTaskFn }
+) {
+ let dialogReadyPromise = waitForWidgetReady();
+ let { requestId } = await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ methodData,
+ details,
+ options,
+ },
+ ],
+ merchantTaskFn
+ );
+ ok(requestId, "requestId should be defined");
+
+ // get a reference to the UI dialog and the requestId
+ let [win] = await Promise.all([
+ getPaymentWidget(requestId),
+ dialogReadyPromise,
+ ]);
+ ok(win, "Got payment widget");
+ is(win.closed, false, "dialog should not be closed");
+
+ let frame = await getPaymentFrame(win);
+ ok(frame, "Got payment frame");
+
+ await dialogReadyPromise;
+ info("dialog ready");
+
+ await spawnPaymentDialogTask(frame, () => {
+ let elementHeight = element => element.getBoundingClientRect().height;
+ content.isHidden = element => elementHeight(element) == 0;
+ content.isVisible = element => elementHeight(element) > 0;
+ content.fillField = async function fillField(field, value) {
+ // Keep in-sync with the copy in payments_common.js but with EventUtils methods called on a
+ // EventUtils object.
+ field.focus();
+ if (field.localName == "select") {
+ if (field.value == value) {
+ // Do nothing
+ return;
+ }
+ field.value = value;
+ field.dispatchEvent(
+ new content.window.Event("input", { bubbles: true })
+ );
+ field.dispatchEvent(
+ new content.window.Event("change", { bubbles: true })
+ );
+ return;
+ }
+ while (field.value) {
+ EventUtils.sendKey("BACK_SPACE", content.window);
+ }
+ EventUtils.sendString(value, content.window);
+ };
+ });
+
+ return { win, requestId, frame };
+}
+
+/**
+ * Open a merchant tab with the given merchantTaskFn to create a PaymentRequest
+ * and then open the associated PaymentRequest dialog in a new tab and run the
+ * associated dialogTaskFn. The same taskArgs are passed to both functions.
+ *
+ * @param {Function} merchantTaskFn
+ * @param {Function} dialogTaskFn
+ * @param {Object} taskArgs
+ * @param {Object} options
+ * @param {string} options.origin
+ */
+async function spawnInDialogForMerchantTask(
+ merchantTaskFn,
+ dialogTaskFn,
+ taskArgs,
+ { browser, origin = "https://example.com" } = {
+ origin: "https://example.com",
+ }
+) {
+ await withMerchantTab(
+ {
+ browser,
+ url: origin + BLANK_PAGE_PATH,
+ },
+ async merchBrowser => {
+ let { win, frame } = await setupPaymentDialog(merchBrowser, {
+ ...taskArgs,
+ merchantTaskFn,
+ });
+
+ await spawnPaymentDialogTask(frame, dialogTaskFn, taskArgs);
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(
+ () => win.closed,
+ "dialog should be closed"
+ );
+ }
+ );
+}
+
+async function loginAndCompletePayment(frame) {
+ let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.completePayment);
+ await osKeyStoreLoginShown;
+}
+
+async function setupFormAutofillStorage() {
+ await formAutofillStorage.initialize();
+}
+
+function cleanupFormAutofillStorage() {
+ formAutofillStorage.addresses.removeAll();
+ formAutofillStorage.creditCards.removeAll();
+}
+
+add_task(async function setup_head() {
+ SpecialPowers.registerConsoleListener(function onConsoleMessage(msg) {
+ if (msg.isWarning || !msg.errorMessage) {
+ // Ignore warnings and non-errors.
+ return;
+ }
+ if (
+ msg.category == "CSP_CSPViolationWithURI" &&
+ msg.errorMessage.includes("at inline")
+ ) {
+ // Ignore unknown CSP error.
+ return;
+ }
+ if (
+ msg.message &&
+ msg.message.match(/docShell is null.*BrowserUtils.jsm/)
+ ) {
+ // Bug 1478142 - Console spam from the Find Toolbar.
+ return;
+ }
+ if (msg.message && msg.message.match(/PrioEncoder is not defined/)) {
+ // Bug 1492638 - Console spam from TelemetrySession.
+ return;
+ }
+ if (
+ msg.message &&
+ msg.message.match(/devicePixelRatio.*FaviconLoader.jsm/)
+ ) {
+ return;
+ }
+ if (
+ msg.errorMessage == "AbortError: The operation was aborted. " &&
+ msg.sourceName == "" &&
+ msg.lineNumber == 0
+ ) {
+ return;
+ }
+ ok(false, msg.message || msg.errorMessage);
+ });
+ OSKeyStoreTestUtils.setup();
+ await setupFormAutofillStorage();
+ registerCleanupFunction(async function cleanup() {
+ paymentSrv.cleanup();
+ cleanupFormAutofillStorage();
+ await OSKeyStoreTestUtils.cleanup();
+ Services.prefs.clearUserPref(RESPONSE_TIMEOUT_PREF);
+ Services.prefs.clearUserPref(SAVE_CREDITCARD_DEFAULT_PREF);
+ Services.prefs.clearUserPref(SAVE_ADDRESS_DEFAULT_PREF);
+ SpecialPowers.postConsoleSentinel();
+ // CreditCard.jsm is imported into the global scope. It needs to be deleted
+ // else it outlives the test and is reported as a leak.
+ delete window.CreditCard;
+ });
+});
+
+function deepClone(obj) {
+ return JSON.parse(JSON.stringify(obj));
+}
+
+async function selectPaymentDialogShippingAddressByCountry(frame, country) {
+ await spawnPaymentDialogTask(
+ frame,
+ PTU.DialogContentTasks.selectShippingAddressByCountry,
+ country
+ );
+}
+
+async function navigateToAddAddressPage(frame, aOptions = {}) {
+ ok(aOptions.initialPageId, "initialPageId option supplied");
+ ok(aOptions.addressPageId, "addressPageId option supplied");
+ ok(aOptions.addLinkSelector, "addLinkSelector option supplied");
+
+ await spawnPaymentDialogTask(
+ frame,
+ async options => {
+ let { PaymentTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ info("navigateToAddAddressPage: check we're on the expected page first");
+ await PaymentTestUtils.DialogContentUtils.waitForState(
+ content,
+ state => {
+ info(
+ "current page state: " +
+ state.page.id +
+ " waiting for: " +
+ options.initialPageId
+ );
+ return state.page.id == options.initialPageId;
+ },
+ "Check initial page state"
+ );
+
+ // click through to add/edit address page
+ info("navigateToAddAddressPage: click the link");
+ let addLink = content.document.querySelector(options.addLinkSelector);
+ addLink.click();
+
+ info("navigateToAddAddressPage: wait for address page");
+ await PaymentTestUtils.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == options.addressPageId && !state.page.guid;
+ },
+ "Check add page state"
+ );
+ },
+ aOptions
+ );
+}
+
+async function navigateToAddShippingAddressPage(frame, aOptions = {}) {
+ let options = Object.assign(
+ {
+ addLinkSelector:
+ 'address-picker[selected-state-key="selectedShippingAddress"] .add-link',
+ initialPageId: "payment-summary",
+ addressPageId: "shipping-address-page",
+ },
+ aOptions
+ );
+ await navigateToAddAddressPage(frame, options);
+}
+
+async function fillInBillingAddressForm(frame, aAddress, aOptions = {}) {
+ // For now billing and shipping address forms have the same fields but that may
+ // change so use separarate helpers.
+ let address = Object.assign({}, aAddress);
+ // Email isn't used on address forms, only payer/contact ones.
+ delete address.email;
+ let options = Object.assign(
+ {
+ addressPageId: "billing-address-page",
+ expectedSelectedStateKey: ["basic-card-page", "billingAddressGUID"],
+ },
+ aOptions
+ );
+ return fillInAddressForm(frame, address, options);
+}
+
+async function fillInShippingAddressForm(frame, aAddress, aOptions) {
+ let address = Object.assign({}, aAddress);
+ // Email isn't used on address forms, only payer/contact ones.
+ delete address.email;
+ return fillInAddressForm(frame, address, {
+ expectedSelectedStateKey: ["selectedShippingAddress"],
+ ...aOptions,
+ });
+}
+
+async function fillInPayerAddressForm(frame, aAddress) {
+ let address = Object.assign({}, aAddress);
+ let payerFields = [
+ "given-name",
+ "additional-name",
+ "family-name",
+ "tel",
+ "email",
+ ];
+ for (let fieldName of Object.keys(address)) {
+ if (payerFields.includes(fieldName)) {
+ continue;
+ }
+ delete address[fieldName];
+ }
+ return fillInAddressForm(frame, address, {
+ expectedSelectedStateKey: ["selectedPayerAddress"],
+ });
+}
+
+/**
+ * @param {HTMLElement} frame
+ * @param {object} aAddress
+ * @param {object} [aOptions = {}]
+ * @param {boolean} [aOptions.setPersistCheckedValue = undefined] How to set the persist checkbox.
+ * @param {string[]} [expectedSelectedStateKey = undefined] The expected selectedStateKey for
+ address-page.
+ */
+async function fillInAddressForm(frame, aAddress, aOptions = {}) {
+ await spawnPaymentDialogTask(
+ frame,
+ async args => {
+ let { address, options = {} } = args;
+ let { requestStore } = Cu.waiveXrays(
+ content.document.querySelector("payment-dialog")
+ );
+ let currentState = requestStore.getState();
+ let addressForm = content.document.getElementById(currentState.page.id);
+ ok(
+ addressForm,
+ "found the addressForm: " + addressForm.getAttribute("id")
+ );
+
+ if (options.expectedSelectedStateKey) {
+ Assert.deepEqual(
+ addressForm.getAttribute("selected-state-key").split("|"),
+ options.expectedSelectedStateKey,
+ "Check address page selectedStateKey"
+ );
+ }
+
+ if (typeof address.country != "undefined") {
+ // Set the country first so that the appropriate fields are visible.
+ let countryField = addressForm.querySelector("#country");
+ ok(!countryField.disabled, "Country Field shouldn't be disabled");
+ await content.fillField(countryField, address.country);
+ is(
+ countryField.value,
+ address.country,
+ "country value is correct after fillField"
+ );
+ }
+
+ // fill the form
+ info(
+ "fillInAddressForm: fill the form with address: " +
+ JSON.stringify(address)
+ );
+ for (let [key, val] of Object.entries(address)) {
+ let field = addressForm.querySelector(`#${key}`);
+ if (!field) {
+ ok(false, `${key} field not found`);
+ }
+ ok(!field.disabled, `Field #${key} shouldn't be disabled`);
+ await content.fillField(field, val);
+ is(field.value, val, `${key} value is correct after fillField`);
+ }
+ let persistCheckbox = Cu.waiveXrays(
+ addressForm.querySelector(".persist-checkbox")
+ );
+ // only touch the checked state if explicitly told to in the options
+ if (options.hasOwnProperty("setPersistCheckedValue")) {
+ info(
+ "fillInAddressForm: Manually setting the persist checkbox checkedness to: " +
+ options.setPersistCheckedValue
+ );
+ Cu.waiveXrays(persistCheckbox).checked = options.setPersistCheckedValue;
+ }
+ info(
+ `fillInAddressForm, persistCheckbox.checked: ${persistCheckbox.checked}`
+ );
+ },
+ { address: aAddress, options: aOptions }
+ );
+}
+
+async function verifyPersistCheckbox(frame, aOptions = {}) {
+ await spawnPaymentDialogTask(
+ frame,
+ async args => {
+ let { options = {} } = args;
+ // ensure card/address is persisted or not based on the temporary option given
+ info("verifyPersistCheckbox, got options: " + JSON.stringify(options));
+ let persistCheckbox = Cu.waiveXrays(
+ content.document.querySelector(options.checkboxSelector)
+ );
+
+ if (options.isEditing) {
+ ok(
+ persistCheckbox.hidden,
+ "checkbox should be hidden when editing a record"
+ );
+ } else {
+ ok(
+ !persistCheckbox.hidden,
+ "checkbox should be visible when adding a new record"
+ );
+ is(
+ persistCheckbox.checked,
+ options.expectPersist,
+ `persist checkbox state is expected to be ${options.expectPersist}`
+ );
+ }
+ },
+ { options: aOptions }
+ );
+}
+
+async function verifyCardNetwork(frame, aOptions = {}) {
+ aOptions.supportedNetworks = CreditCard.SUPPORTED_NETWORKS;
+
+ await spawnPaymentDialogTask(
+ frame,
+ async args => {
+ let { options = {} } = args;
+ // ensure the network picker is visible, has the right contents and expected value
+ let networkSelect = Cu.waiveXrays(
+ content.document.querySelector(options.networkSelector)
+ );
+ ok(
+ content.isVisible(networkSelect),
+ "The network selector should always be visible"
+ );
+ is(
+ networkSelect.childElementCount,
+ options.supportedNetworks.length + 1,
+ "Should have one more than the number of supported networks"
+ );
+ is(
+ networkSelect.children[0].value,
+ "",
+ "The first option should be the blank/empty option"
+ );
+ is(
+ networkSelect.value,
+ options.expectedNetwork,
+ `The network picker should have the expected value`
+ );
+ },
+ { options: aOptions }
+ );
+}
+
+async function submitAddressForm(
+ frame,
+ aAddress,
+ aOptions = {
+ nextPageId: "payment-summary",
+ }
+) {
+ await spawnPaymentDialogTask(
+ frame,
+ async args => {
+ let { options = {} } = args;
+ let nextPageId = options.nextPageId || "payment-summary";
+ let { PaymentTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ let oldState = await PaymentTestUtils.DialogContentUtils.getCurrentState(
+ content
+ );
+ let pageId = oldState.page.id;
+
+ // submit the form to return to summary page
+ content.document.querySelector(`#${pageId} button.primary`).click();
+
+ let currState = await PaymentTestUtils.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == nextPageId;
+ },
+ `submitAddressForm: Switched back to ${nextPageId}`
+ );
+
+ let savedCount = Object.keys(currState.savedAddresses).length;
+ let tempCount = Object.keys(currState.tempAddresses).length;
+ let oldSavedCount = Object.keys(oldState.savedAddresses).length;
+ let oldTempCount = Object.keys(oldState.tempAddresses).length;
+
+ if (options.isEditing) {
+ is(tempCount, oldTempCount, "tempAddresses count didn't change");
+ is(savedCount, oldSavedCount, "savedAddresses count didn't change");
+ } else if (options.expectPersist) {
+ is(tempCount, oldTempCount, "tempAddresses count didn't change");
+ is(savedCount, oldSavedCount + 1, "Entry added to savedAddresses");
+ } else {
+ is(tempCount, oldTempCount + 1, "Entry added to tempAddresses");
+ is(savedCount, oldSavedCount, "savedAddresses count didn't change");
+ }
+ },
+ { address: aAddress, options: aOptions }
+ );
+}
+
+async function manuallyAddShippingAddress(frame, aAddress, aOptions = {}) {
+ let options = Object.assign(
+ {
+ expectPersist: true,
+ isEditing: false,
+ },
+ aOptions,
+ {
+ checkboxSelector: "#shipping-address-page .persist-checkbox",
+ }
+ );
+ await navigateToAddShippingAddressPage(frame);
+ info(
+ "manuallyAddShippingAddress, fill in address form with options: " +
+ JSON.stringify(options)
+ );
+ await fillInShippingAddressForm(frame, aAddress, options);
+ info(
+ "manuallyAddShippingAddress, verifyPersistCheckbox with options: " +
+ JSON.stringify(options)
+ );
+ await verifyPersistCheckbox(frame, options);
+ await submitAddressForm(frame, aAddress, options);
+}
+
+async function navigateToAddCardPage(
+ frame,
+ aOptions = {
+ addLinkSelector: "payment-method-picker .add-link",
+ }
+) {
+ await spawnPaymentDialogTask(
+ frame,
+ async options => {
+ let { PaymentTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PaymentTestUtils.jsm"
+ );
+
+ // check were on the summary page first
+ await PaymentTestUtils.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return !state.page.id || state.page.id == "payment-summary";
+ },
+ "Check summary page state"
+ );
+
+ // click through to add/edit card page
+ let addLink = content.document.querySelector(options.addLinkSelector);
+ addLink.click();
+
+ // wait for card page
+ await PaymentTestUtils.DialogContentUtils.waitForState(
+ content,
+ state => {
+ return state.page.id == "basic-card-page";
+ },
+ "Check add/edit page state"
+ );
+ },
+ aOptions
+ );
+}
+
+async function fillInCardForm(frame, aCard, aOptions = {}) {
+ await spawnPaymentDialogTask(
+ frame,
+ async args => {
+ let { card, options = {} } = args;
+
+ // fill the form
+ info("fillInCardForm: fill the form with card: " + JSON.stringify(card));
+ for (let [key, val] of Object.entries(card)) {
+ let field = content.document.getElementById(key);
+ if (!field) {
+ ok(false, `${key} field not found`);
+ }
+ ok(!field.disabled, `Field #${key} shouldn't be disabled`);
+ // Reset the value first so that we properly handle typing the value
+ // already selected which may select another option with the same prefix.
+ field.value = "";
+ ok(!field.value, "Field value should be reset before typing");
+ field.blur();
+ field.focus();
+ // Using waitForEvent here causes the test to hang, but
+ // waitForCondition and checking activeElement does the trick. The root cause
+ // of this should be investigated further.
+ await ContentTaskUtils.waitForCondition(
+ () => field == content.document.activeElement,
+ `Waiting for field #${key} to get focus`
+ );
+ if (key == "billingAddressGUID") {
+ // Can't type the value in, press Down until the value is found
+ content.fillField(field, val);
+ } else {
+ // cc-exp-* fields are numbers so convert to strings and pad left with 0
+ let fillValue = val.toString().padStart(2, "0");
+ EventUtils.synthesizeKey(
+ fillValue,
+ {},
+ Cu.waiveXrays(content.window)
+ );
+ }
+ // cc-exp-* field values are not padded, so compare with unpadded string.
+ is(
+ field.value,
+ val.toString(),
+ `${key} value is correct after sendString`
+ );
+ }
+
+ info(
+ [...content.document.getElementById("cc-exp-year").options]
+ .map(op => op.label)
+ .join(",")
+ );
+
+ let persistCheckbox = content.document.querySelector(
+ options.checkboxSelector
+ );
+ // only touch the checked state if explicitly told to in the options
+ if (options.hasOwnProperty("setPersistCheckedValue")) {
+ info(
+ "fillInCardForm: Manually setting the persist checkbox checkedness to: " +
+ options.setPersistCheckedValue
+ );
+ Cu.waiveXrays(persistCheckbox).checked = options.setPersistCheckedValue;
+ }
+ },
+ { card: aCard, options: aOptions }
+ );
+}
diff --git a/browser/components/payments/test/mochitest/formautofill/mochitest.ini b/browser/components/payments/test/mochitest/formautofill/mochitest.ini
new file mode 100644
index 0000000000..9740f9e3e8
--- /dev/null
+++ b/browser/components/payments/test/mochitest/formautofill/mochitest.ini
@@ -0,0 +1,10 @@
+[DEFAULT]
+# This manifest mostly exists so that the support-files below can be referenced
+# from a relative path of formautofill/* from the tests in the above directory
+# to resemble the layout in the shipped JAR file.
+support-files =
+ ../../../../../../browser/extensions/formautofill/content/editCreditCard.xhtml
+ ../../../../../../browser/extensions/formautofill/content/editAddress.xhtml
+
+skip-if = true # Bug 1446164
+[test_editCreditCard.html]
diff --git a/browser/components/payments/test/mochitest/formautofill/test_editCreditCard.html b/browser/components/payments/test/mochitest/formautofill/test_editCreditCard.html
new file mode 100644
index 0000000000..4d4a06e35a
--- /dev/null
+++ b/browser/components/payments/test/mochitest/formautofill/test_editCreditCard.html
@@ -0,0 +1,34 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that editCreditCard.xhtml is accessible for tests in the parent directory.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test that editCreditCard.xhtml is accessible</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <iframe id="editCreditCard" src="editCreditCard.xhtml"></iframe>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="application/javascript">
+
+add_task(async function test_editCreditCard() {
+ let editCreditCard = document.getElementById("editCreditCard").contentWindow;
+ await SimpleTest.promiseFocus(editCreditCard);
+ ok(editCreditCard.document.getElementById("form"), "Check form is present");
+ ok(editCreditCard.document.getElementById("cc-number"), "Check cc-number is present");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/mochitest.ini b/browser/components/payments/test/mochitest/mochitest.ini
new file mode 100644
index 0000000000..d1d2907496
--- /dev/null
+++ b/browser/components/payments/test/mochitest/mochitest.ini
@@ -0,0 +1,37 @@
+[DEFAULT]
+support-files =
+ !/browser/extensions/formautofill/content/editAddress.xhtml
+ !/browser/extensions/formautofill/content/editCreditCard.xhtml
+ ../../../../../browser/extensions/formautofill/content/autofillEditForms.js
+ ../../../../../browser/extensions/formautofill/skin/shared/editDialog-shared.css
+ ../../../../../testing/modules/sinon-7.2.7.js
+ # paymentRequest.xhtml is needed for `importDialogDependencies` so that the relative paths of
+ # formautofill/edit*.xhtml work from the *-form elements in paymentRequest.xhtml.
+ ../../res/paymentRequest.xhtml
+ ../../res/**
+ payments_common.js
+skip-if = true || !e10s # Bug 1515048 - Disable for now. Bug 1365964 - Payment Request isn't implemented for non-e10s.
+
+[test_accepted_cards.html]
+[test_address_form.html]
+[test_address_option.html]
+skip-if = os == "linux" || os == "win" # Bug 1493216
+[test_address_picker.html]
+[test_basic_card_form.html]
+skip-if = debug || asan # Bug 1493349
+[test_basic_card_option.html]
+[test_billing_address_picker.html]
+[test_completion_error_page.html]
+[test_currency_amount.html]
+[test_labelled_checkbox.html]
+[test_order_details.html]
+[test_payer_address_picker.html]
+[test_payment_dialog.html]
+[test_payment_dialog_required_top_level_items.html]
+[test_payment_details_item.html]
+[test_payment_method_picker.html]
+[test_rich_select.html]
+[test_shipping_option_picker.html]
+[test_ObservedPropertiesMixin.html]
+[test_PaymentsStore.html]
+[test_PaymentStateSubscriberMixin.html]
diff --git a/browser/components/payments/test/mochitest/payments_common.js b/browser/components/payments/test/mochitest/payments_common.js
new file mode 100644
index 0000000000..8e48585318
--- /dev/null
+++ b/browser/components/payments/test/mochitest/payments_common.js
@@ -0,0 +1,154 @@
+"use strict";
+
+/* exported asyncElementRendered, promiseStateChange, promiseContentToChromeMessage, deepClone,
+ PTU, registerConsoleFilter, fillField, importDialogDependencies */
+
+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}
+ */
+function asyncElementRendered() {
+ return Promise.resolve();
+}
+
+function promiseStateChange(store) {
+ return new Promise(resolve => {
+ store.subscribe({
+ 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);
+ });
+ });
+}
+
+/**
+ * Import the templates and stylesheets from the real shipping dialog to avoid
+ * duplication in the tests.
+ * @param {HTMLIFrameElement} templateFrame - Frame to copy the resources from
+ * @param {HTMLElement} destinationEl - Where to append the copied resources
+ */
+function importDialogDependencies(templateFrame, destinationEl) {
+ let templates = templateFrame.contentDocument.querySelectorAll("template");
+ isnot(templates, null, "Check some templates found");
+ for (let template of templates) {
+ let imported = document.importNode(template, true);
+ destinationEl.appendChild(imported);
+ }
+
+ let baseURL = new URL("../../res/", window.location.href);
+ let stylesheetLinks = templateFrame.contentDocument.querySelectorAll(
+ "link[rel~='stylesheet']"
+ );
+ for (let stylesheet of stylesheetLinks) {
+ let imported = document.importNode(stylesheet, true);
+ imported.href = new URL(imported.getAttribute("href"), baseURL);
+ destinationEl.appendChild(imported);
+ }
+}
+
+function deepClone(obj) {
+ return JSON.parse(JSON.stringify(obj));
+}
+
+/**
+ * @param {HTMLElement} field
+ * @param {string} value
+ * @note This is async in case we need to make it async to handle focus in the future.
+ * @note Keep in sync with the copy in head.js
+ */
+async function fillField(field, value) {
+ field.focus();
+ if (field.localName == "select") {
+ if (field.value == value) {
+ // Do nothing
+ return;
+ }
+ field.value = value;
+ field.dispatchEvent(new Event("input", { bubbles: true }));
+ field.dispatchEvent(new Event("change", { bubbles: true }));
+ return;
+ }
+ while (field.value) {
+ sendKey("BACK_SPACE");
+ }
+ sendString(value);
+}
+
+/**
+ * If filterFunction is a function which returns true given a console message
+ * then the test won't fail from that message.
+ */
+let filterFunction = null;
+function registerConsoleFilter(filterFn) {
+ filterFunction = filterFn;
+}
+
+// Listen for errors to fail tests
+SpecialPowers.registerConsoleListener(function onConsoleMessage(msg) {
+ if (
+ msg.isWarning ||
+ !msg.errorMessage ||
+ msg.errorMessage == "paymentRequest.xhtml:"
+ ) {
+ // Ignore warnings and non-errors.
+ return;
+ }
+ if (
+ msg.category == "CSP_CSPViolationWithURI" &&
+ msg.errorMessage.includes("at inline")
+ ) {
+ // Ignore unknown CSP error.
+ return;
+ }
+ if (
+ msg.message &&
+ msg.message.includes("Security Error: Content at http://mochi.test:8888")
+ ) {
+ // Check for same-origin policy violations and ignore specific errors
+ if (
+ msg.message.includes("icon-credit-card-generic.svg") ||
+ msg.message.includes("accepted-cards.css") ||
+ msg.message.includes("editDialog-shared.css") ||
+ msg.message.includes("editAddress.css") ||
+ msg.message.includes("editDialog.css") ||
+ msg.message.includes("editCreditCard.css")
+ ) {
+ return;
+ }
+ }
+ if (msg.message == "SENTINEL") {
+ filterFunction = null;
+ }
+ if (filterFunction && filterFunction(msg)) {
+ return;
+ }
+ ok(false, msg.message || msg.errorMessage);
+});
+
+SimpleTest.registerCleanupFunction(function cleanup() {
+ SpecialPowers.postConsoleSentinel();
+});
diff --git a/browser/components/payments/test/mochitest/test_ObservedPropertiesMixin.html b/browser/components/payments/test/mochitest/test_ObservedPropertiesMixin.html
new file mode 100644
index 0000000000..00dff76a5f
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_ObservedPropertiesMixin.html
@@ -0,0 +1,116 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the ObservedPropertiesMixin
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the ObservedPropertiesMixin</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <test-element id="el1" one="foo" two-word="bar"></test-element>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the ObservedPropertiesMixin **/
+
+import ObservedPropertiesMixin from "../../res/mixins/ObservedPropertiesMixin.js";
+
+class TestElement extends ObservedPropertiesMixin(HTMLElement) {
+ static get observedAttributes() {
+ return ["one", "two-word"];
+ }
+
+ render() {
+ this.textContent = JSON.stringify({
+ one: this.one,
+ twoWord: this.twoWord,
+ });
+ }
+}
+
+customElements.define("test-element", TestElement);
+let el1 = document.getElementById("el1");
+
+add_task(async function test_default_properties() {
+ is(el1.one, "foo", "Check .one matches @one");
+ is(el1.twoWord, "bar", "Check .twoWord matches @two-word");
+ let expected = `{"one":"foo","twoWord":"bar"}`;
+ is(el1.textContent, expected, "Check textContent");
+});
+
+add_task(async function test_set_properties() {
+ el1.one = "a";
+ el1.twoWord = "b";
+ is(el1.one, "a", "Check .one value");
+ is(el1.getAttribute("one"), "a", "Check @one");
+ is(el1.twoWord, "b", "Check .twoWord value");
+ is(el1.getAttribute("two-word"), "b", "Check @two-word");
+ let expected = `{"one":"a","twoWord":"b"}`;
+ await asyncElementRendered();
+ is(el1.textContent, expected, "Check textContent");
+});
+
+add_task(async function test_set_attributes() {
+ el1.setAttribute("one", "X");
+ el1.setAttribute("two-word", "Y");
+ is(el1.one, "X", "Check .one value");
+ is(el1.getAttribute("one"), "X", "Check @one");
+ is(el1.twoWord, "Y", "Check .twoWord value");
+ is(el1.getAttribute("two-word"), "Y", "Check @two-word");
+ let expected = `{"one":"X","twoWord":"Y"}`;
+ await asyncElementRendered();
+ is(el1.textContent, expected, "Check textContent");
+});
+
+add_task(async function test_async_render() {
+ // Setup
+ el1.setAttribute("one", "1");
+ el1.setAttribute("two-word", "2");
+ await asyncElementRendered(); // Wait for the async render
+
+ el1.setAttribute("one", "new1");
+
+ is(el1.one, "new1", "Check .one value");
+ is(el1.getAttribute("one"), "new1", "Check @one");
+ is(el1.twoWord, "2", "Check .twoWord value");
+ is(el1.getAttribute("two-word"), "2", "Check @two-word");
+ let expected = `{"one":"1","twoWord":"2"}`;
+ is(el1.textContent, expected, "Check textContent is still old value due to async rendering");
+ await asyncElementRendered();
+ expected = `{"one":"new1","twoWord":"2"}`;
+ is(el1.textContent, expected, "Check textContent now has the new value");
+});
+
+add_task(async function test_batched_render() {
+ // Setup
+ el1.setAttribute("one", "1");
+ el1.setAttribute("two-word", "2");
+ await asyncElementRendered();
+
+ el1.setAttribute("one", "new1");
+ el1.setAttribute("two-word", "new2");
+
+ is(el1.one, "new1", "Check .one value");
+ is(el1.getAttribute("one"), "new1", "Check @one");
+ is(el1.twoWord, "new2", "Check .twoWord value");
+ is(el1.getAttribute("two-word"), "new2", "Check @two-word");
+ let expected = `{"one":"1","twoWord":"2"}`;
+ is(el1.textContent, expected, "Check textContent is still old value due to async rendering");
+ await asyncElementRendered();
+ expected = `{"one":"new1","twoWord":"new2"}`;
+ is(el1.textContent, expected, "Check textContent now has the new value");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_PaymentStateSubscriberMixin.html b/browser/components/payments/test/mochitest/test_PaymentStateSubscriberMixin.html
new file mode 100644
index 0000000000..73e3786288
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_PaymentStateSubscriberMixin.html
@@ -0,0 +1,79 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the PaymentStateSubscriberMixin
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the PaymentStateSubscriberMixin</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <test-element id="el1"></test-element>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the PaymentStateSubscriberMixin **/
+
+/* global sinon */
+
+import PaymentStateSubscriberMixin from "../../res/mixins/PaymentStateSubscriberMixin.js";
+
+class TestElement extends PaymentStateSubscriberMixin(HTMLElement) {
+ render(state) {
+ this.textContent = JSON.stringify(state);
+ }
+}
+
+// We must spy on the prototype by creating the instance in order to test Custom Element reactions.
+sinon.spy(TestElement.prototype, "disconnectedCallback");
+
+customElements.define("test-element", TestElement);
+let el1 = document.getElementById("el1");
+
+sinon.spy(el1, "render");
+sinon.spy(el1, "stateChangeCallback");
+
+add_task(async function test_initialState() {
+ let parsedState = JSON.parse(el1.textContent);
+ ok(!!parsedState.request, "Check initial state contains `request`");
+ ok(!!parsedState.savedAddresses, "Check initial state contains `savedAddresses`");
+ ok(!!parsedState.savedBasicCards, "Check initial state contains `savedBasicCards`");
+});
+
+add_task(async function test_async_batched_render() {
+ el1.requestStore.setState({a: 1});
+ el1.requestStore.setState({b: 2});
+ await asyncElementRendered();
+ ok(el1.stateChangeCallback.calledOnce, "stateChangeCallback called once");
+ ok(el1.render.calledOnce, "render called once");
+
+ let parsedState = JSON.parse(el1.textContent);
+ is(parsedState.a, 1, "Check a");
+ is(parsedState.b, 2, "Check b");
+});
+
+add_task(async function test_disconnect() {
+ el1.disconnectedCallback.reset();
+ el1.render.reset();
+ el1.stateChangeCallback.reset();
+ el1.remove();
+ ok(el1.disconnectedCallback.calledOnce, "disconnectedCallback called once");
+ await el1.requestStore.setState({a: 3});
+ await asyncElementRendered();
+ ok(el1.stateChangeCallback.notCalled, "stateChangeCallback not called");
+ ok(el1.render.notCalled, "render not called");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_PaymentsStore.html b/browser/components/payments/test/mochitest/test_PaymentsStore.html
new file mode 100644
index 0000000000..26f29e96ba
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_PaymentsStore.html
@@ -0,0 +1,168 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the PaymentsStore
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the PaymentsStore</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the PaymentsStore **/
+
+/* global sinon */
+
+import PaymentsStore from "../../res/PaymentsStore.js";
+
+function assert_throws(block, expectedError, message) {
+ let actual;
+ try {
+ block();
+ } catch (e) {
+ actual = e;
+ }
+ ok(actual, "Expecting exception: " + message);
+ ok(actual instanceof expectedError,
+ `Check error type is ${expectedError.prototype.name}: ${message}`);
+}
+
+add_task(async function test_defaultState() {
+ ok(!!PaymentsStore, "Check PaymentsStore import");
+ let ps = new PaymentsStore({
+ foo: "bar",
+ });
+
+ let state = ps.getState();
+ ok(!!state, "Check state is truthy");
+ is(state.foo, "bar", "Check .foo");
+
+ assert_throws(() => state.foo = "new", TypeError, "Assigning to existing prop. should throw");
+ assert_throws(() => state.other = "something", TypeError, "Adding a new prop. should throw");
+ assert_throws(() => delete state.foo, TypeError, "Deleting a prop. should throw");
+});
+
+add_task(async function test_setState() {
+ let ps = new PaymentsStore({});
+
+ ps.setState({
+ one: "one",
+ });
+ let state = ps.getState();
+ is(Object.keys(state).length, 1, "Should only have 1 prop. set");
+ is(state.one, "one", "Check .one");
+
+ ps.setState({
+ two: 2,
+ });
+ state = ps.getState();
+ is(Object.keys(state).length, 2, "Should have 2 props. set");
+ is(state.one, "one", "Check .one");
+ is(state.two, 2, "Check .two");
+
+ ps.setState({
+ one: "a",
+ two: "b",
+ });
+ state = ps.getState();
+ is(state.one, "a", "Check .one");
+ is(state.two, "b", "Check .two");
+
+ info("check consecutive setState for the same prop");
+ ps.setState({
+ one: "c",
+ });
+ ps.setState({
+ one: "d",
+ });
+ state = ps.getState();
+ is(Object.keys(state).length, 2, "Should have 2 props. set");
+ is(state.one, "d", "Check .one");
+ is(state.two, "b", "Check .two");
+});
+
+add_task(async function test_subscribe_unsubscribe() {
+ let ps = new PaymentsStore({});
+ let subscriber = {
+ stateChangePromise: null,
+ _stateChangeResolver: null,
+
+ reset() {
+ this.stateChangePromise = new Promise(resolve => {
+ this._stateChangeResolver = resolve;
+ });
+ },
+
+ stateChangeCallback(state) {
+ this._stateChangeResolver(state);
+ this.stateChangePromise = new Promise(resolve => {
+ this._stateChangeResolver = resolve;
+ });
+ },
+ };
+
+ sinon.spy(subscriber, "stateChangeCallback");
+ subscriber.reset();
+ ps.subscribe(subscriber);
+ info("subscribe the same listener twice to ensure it still doesn't call the callback");
+ ps.subscribe(subscriber);
+ ok(subscriber.stateChangeCallback.notCalled,
+ "Check not called synchronously when subscribing");
+
+ let changePromise = subscriber.stateChangePromise;
+ ps.setState({
+ a: 1,
+ });
+ ok(subscriber.stateChangeCallback.notCalled,
+ "Check not called synchronously for changes");
+ let state = await changePromise;
+ is(state, subscriber.stateChangeCallback.getCall(0).args[0],
+ "Check resolved state is last state");
+ is(JSON.stringify(state), `{"a":1}`, "Check callback state");
+
+ info("Testing consecutive setState");
+ subscriber.reset();
+ subscriber.stateChangeCallback.reset();
+ changePromise = subscriber.stateChangePromise;
+ ps.setState({
+ a: 2,
+ });
+ ps.setState({
+ a: 3,
+ });
+ ok(subscriber.stateChangeCallback.notCalled,
+ "Check not called synchronously for changes");
+ state = await changePromise;
+ is(state, subscriber.stateChangeCallback.getCall(0).args[0],
+ "Check resolved state is last state");
+ is(JSON.stringify(subscriber.stateChangeCallback.getCall(0).args[0]), `{"a":3}`,
+ "Check callback state matches second setState");
+
+ info("test unsubscribe");
+ subscriber.stateChangeCallback = function unexpectedChange() {
+ ok(false, "stateChangeCallback shouldn't be called after unsubscribing");
+ };
+ ps.unsubscribe(subscriber);
+ ps.setState({
+ a: 4,
+ });
+ await Promise.resolve("giving a chance for the callback to be called");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_accepted_cards.html b/browser/components/payments/test/mochitest/test_accepted_cards.html
new file mode 100644
index 0000000000..8e1da1bf3c
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_accepted_cards.html
@@ -0,0 +1,111 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the accepted-cards element
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the accepted-cards element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/accepted-cards.css"/>
+</head>
+<body>
+ <p id="display">
+ <accepted-cards label="Accepted:"></accepted-cards>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the accepted-cards component **/
+
+/* global sinon, PaymentDialogUtils */
+
+import "../../res/components/accepted-cards.js";
+import {requestStore} from "../../res/mixins/PaymentStateSubscriberMixin.js";
+let emptyState = requestStore.getState();
+let acceptedElem = document.querySelector("accepted-cards");
+let allNetworks = PaymentDialogUtils.getCreditCardNetworks();
+
+add_task(async function test_reConnected() {
+ let itemsCount = acceptedElem.querySelectorAll(".accepted-cards-item").length;
+ is(itemsCount, allNetworks.length, "Same number of items as there are supported networks");
+
+ let container = acceptedElem.parentNode;
+ let removed = container.removeChild(acceptedElem);
+ container.appendChild(removed);
+ let newItemsCount = acceptedElem.querySelectorAll(".accepted-cards-item").length;
+ is(itemsCount, newItemsCount, "Number of items doesnt changed when re-connected");
+});
+
+add_task(async function test_someAccepted() {
+ let supportedNetworks = ["discover", "amex"];
+ let paymentMethods = [{
+ supportedMethods: "basic-card",
+ data: {
+ supportedNetworks,
+ },
+ }];
+ requestStore.setState({
+ request: Object.assign({}, emptyState.request, {
+ paymentMethods,
+ }),
+ });
+ await asyncElementRendered();
+
+ let showingItems = acceptedElem.querySelectorAll(".accepted-cards-item:not([hidden])");
+ is(showingItems.length, 2,
+ "Expected 2 items to be showing when 2 supportedNetworks are indicated");
+ for (let network of allNetworks) {
+ if (supportedNetworks.includes(network)) {
+ ok(acceptedElem.querySelector(`[data-network-id='${network}']:not([hidden])`),
+ `Item for the ${network} network expected to be visible`);
+ } else {
+ ok(acceptedElem.querySelector(`[data-network-id='${network}'][hidden]`),
+ `Item for the ${network} network expected to be hidden`);
+ }
+ }
+});
+
+add_task(async function test_officialBranding() {
+ // verify we get the expected result when isOfficialBranding returns true
+ sinon.stub(PaymentDialogUtils, "isOfficialBranding").callsFake(() => { return true; });
+
+ let container = acceptedElem.parentNode;
+ let removed = container.removeChild(acceptedElem);
+ container.appendChild(removed);
+
+ ok(PaymentDialogUtils.isOfficialBranding.calledOnce,
+ "isOfficialBranding was called");
+ ok(acceptedElem.classList.contains("branded"),
+ "The branded class is added when isOfficialBranding returns true");
+ PaymentDialogUtils.isOfficialBranding.restore();
+
+ // verify we get the expected result when isOfficialBranding returns false
+ sinon.stub(PaymentDialogUtils, "isOfficialBranding").callsFake(() => { return false; });
+
+ // the branded class is toggled in the 'connectedCallback',
+ // so remove and re-add the element to re-evaluate branded-ness
+ removed = container.removeChild(acceptedElem);
+ container.appendChild(removed);
+
+ ok(PaymentDialogUtils.isOfficialBranding.calledOnce,
+ "isOfficialBranding was called");
+ ok(!acceptedElem.classList.contains("branded"),
+ "The branded class is removed when isOfficialBranding returns false");
+ PaymentDialogUtils.isOfficialBranding.restore();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_address_form.html b/browser/components/payments/test/mochitest/test_address_form.html
new file mode 100644
index 0000000000..906e5cc0f5
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_address_form.html
@@ -0,0 +1,955 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the address-form element
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the address-form element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="editDialog-shared.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/containers/address-form.css"/>
+</head>
+<body>
+ <p id="display">
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the address-form element **/
+
+/* global sinon, PaymentDialogUtils */
+
+import AddressForm from "../../res/containers/address-form.js";
+
+let display = document.getElementById("display");
+
+function checkAddressForm(customEl, expectedAddress) {
+ const ADDRESS_PROPERTY_NAMES = [
+ "given-name",
+ "family-name",
+ "organization",
+ "street-address",
+ "address-level2",
+ "address-level1",
+ "postal-code",
+ "country",
+ "email",
+ "tel",
+ ];
+ for (let propName of ADDRESS_PROPERTY_NAMES) {
+ let expectedVal = expectedAddress[propName] || "";
+ is(document.getElementById(propName).value,
+ expectedVal.toString(),
+ `Check ${propName}`);
+ }
+}
+
+function sendStringAndCheckValidity(element, string, isValid) {
+ fillField(element, string);
+ ok(element.checkValidity() == isValid,
+ `${element.id} should be ${isValid ? "valid" : "invalid"} ("${string}")`);
+}
+
+add_task(async function test_initialState() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+
+ await form.requestStore.setState({
+ "test-page": {},
+ });
+
+ let {page} = form.requestStore.getState();
+ is(page.id, "payment-summary", "Check initial page");
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+ is(page.id, "payment-summary", "Check initial page after appending");
+
+ // :-moz-ui-invalid, unlike :invalid, only applies to fields showing the error outline.
+ let fieldsVisiblyInvalid = form.querySelectorAll(":-moz-ui-invalid");
+ is(fieldsVisiblyInvalid.length, 0, "Check no fields are visibly invalid on an empty 'add' form");
+
+ form.remove();
+});
+
+add_task(async function test_pageTitle() {
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ // the element can have all the data attributes. We'll add them all up front
+ let form = new AddressForm();
+ let id = "shipping-address-page";
+ form.id = id;
+ form.dataset.titleAdd = `Add Title`;
+ form.dataset.titleEdit = `Edit Title`;
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+
+ await form.promiseReady;
+ display.appendChild(form);
+
+ let newState = {
+ page: { id },
+ [id]: {},
+ savedAddresses: {
+ [address1.guid]: address1,
+ },
+ request: {
+ paymentDetails: {},
+ paymentOptions: { shippingOption: "shipping" },
+ },
+ };
+ await form.requestStore.setState(newState);
+ await asyncElementRendered();
+ is(form.pageTitleHeading.textContent, "Add Title", "Check 'add' title");
+
+ // test the 'edit' variation
+ newState = deepClone(newState);
+ newState[id].guid = address1.guid;
+ await form.requestStore.setState(newState);
+ await asyncElementRendered();
+ is(form.pageTitleHeading.textContent, "Edit Title", "Check 'edit' title");
+
+ form.remove();
+});
+
+add_task(async function test_backButton() {
+ let form = new AddressForm();
+ form.id = "test-page";
+ form.dataset.titleAdd = "Sample add page title";
+ form.dataset.backButtonLabel = "Back";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+
+ await form.promiseReady;
+ display.appendChild(form);
+
+ await form.requestStore.setState({
+ "test-page": {},
+ page: {
+ id: "test-page",
+ },
+ request: {
+ paymentDetails: {},
+ paymentOptions: {},
+ },
+ });
+ await asyncElementRendered();
+
+ let stateChangePromise = promiseStateChange(form.requestStore);
+ is(form.pageTitleHeading.textContent, "Sample add page title", "Check title");
+
+ is(form.backButton.textContent, "Back", "Check label");
+ form.backButton.scrollIntoView();
+ 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 = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ form.dataset.nextButtonLabel = "Next";
+ form.dataset.errorGenericSave = "Generic error";
+ await form.promiseReady;
+ display.appendChild(form);
+ form.requestStore.setState({
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {},
+ });
+ await asyncElementRendered();
+
+ ok(form.saveButton.disabled, "Save button initially disabled");
+ fillField(form.form.querySelector("#given-name"), "Jaws");
+ fillField(form.form.querySelector("#family-name"), "Swaj");
+ fillField(form.form.querySelector("#organization"), "Allizom");
+ fillField(form.form.querySelector("#street-address"), "404 Internet Super Highway");
+ fillField(form.form.querySelector("#address-level2"), "Firefoxity City");
+ fillField(form.form.querySelector("#country"), "US");
+ fillField(form.form.querySelector("#address-level1"), "CA");
+ fillField(form.form.querySelector("#postal-code"), "00001");
+ fillField(form.form.querySelector("#tel"), "+15555551212");
+
+ ok(!form.saveButton.disabled, "Save button is enabled after filling");
+
+ info("blanking the street-address");
+ fillField(form.form.querySelector("#street-address"), "");
+ ok(form.saveButton.disabled, "Save button is disabled after blanking street-address");
+ form.form.querySelector("#street-address").blur();
+ let fieldsVisiblyInvalid = form.querySelectorAll(":-moz-ui-invalid");
+ is(fieldsVisiblyInvalid.length, 1, "Check 1 field visibly invalid after blanking and blur");
+ is(fieldsVisiblyInvalid[0].id, "street-address", "Check #street-address is visibly invalid");
+
+ fillField(form.form.querySelector("#street-address"), "404 Internet Super Highway");
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0, "Check no fields visibly invalid");
+ ok(!form.saveButton.disabled, "Save button is enabled after re-filling street-address");
+
+ fillField(form.form.querySelector("#country"), "CA");
+ ok(form.saveButton.disabled, "Save button is disabled after changing the country to Canada");
+ fillField(form.form.querySelector("#country"), "US");
+ ok(form.saveButton.disabled,
+ "Save button is disabled after changing the country back to US since address-level1 " +
+ "got cleared when changing countries");
+ fillField(form.form.querySelector("#address-level1"), "CA");
+ ok(!form.saveButton.disabled, "Save button is enabled after re-entering address-level1");
+
+ let messagePromise = promiseContentToChromeMessage("updateAutofillRecord");
+ is(form.saveButton.textContent, "Next", "Check label");
+ form.saveButton.scrollIntoView();
+ synthesizeMouseAtCenter(form.saveButton, {});
+
+ let details = await messagePromise;
+ ok(typeof(details.messageID) == "number" && details.messageID > 0, "Check messageID type");
+ delete details.messageID;
+ is(details.collectionName, "addresses", "Check collectionName");
+ isDeeply(details, {
+ collectionName: "addresses",
+ guid: undefined,
+ messageType: "updateAutofillRecord",
+ record: {
+ "given-name": "Jaws",
+ "family-name": "Swaj",
+ "additional-name": "",
+ "organization": "Allizom",
+ "street-address": "404 Internet Super Highway",
+ "address-level3": "",
+ "address-level2": "Firefoxity City",
+ "address-level1": "CA",
+ "postal-code": "00001",
+ "country": "US",
+ "tel": "+15555551212",
+ },
+ }, "Check event details for the message to chrome");
+ form.remove();
+});
+
+add_task(async function test_genericError() {
+ let form = new AddressForm();
+ form.id = "test-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ 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_edit() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.dataset.updateButtonLabel = "Update";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ await form.requestStore.setState({
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ guid: address1.guid,
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ });
+ await asyncElementRendered();
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check no fields are visibly invalid on an 'edit' form with a complete address");
+ checkAddressForm(form, address1);
+
+ ok(!form.saveButton.disabled, "Save button should be enabled upon edit for a valid address");
+
+ info("test change to minimal record");
+ let minimalAddress = {
+ "given-name": address1["given-name"],
+ guid: "9gnjdhen46",
+ };
+ await form.requestStore.setState({
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ guid: minimalAddress.guid,
+ },
+ savedAddresses: {
+ [minimalAddress.guid]: deepClone(minimalAddress),
+ },
+ });
+ await asyncElementRendered();
+ is(form.saveButton.textContent, "Update", "Check label");
+ checkAddressForm(form, minimalAddress);
+ ok(form.saveButton.disabled, "Save button should be disabled if only the name is filled");
+ ok(form.querySelectorAll(":-moz-ui-invalid").length > 3,
+ "Check fields are visibly invalid on an 'edit' form with only the given-name filled");
+ is(form.querySelectorAll("#country:-moz-ui-invalid").length, 1,
+ "Check that the country `select` is marked as invalid");
+
+ info("change to no selected address");
+ await form.requestStore.setState({
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {},
+ });
+ await asyncElementRendered();
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check no fields are visibly invalid on an empty 'add' form after being an edit form");
+ checkAddressForm(form, {
+ country: "US",
+ });
+ ok(form.saveButton.disabled, "Save button should be disabled for an empty form");
+
+ form.remove();
+});
+
+add_task(async function test_restricted_address_fields() {
+ let form = new AddressForm();
+ form.id = "payer-address-page";
+ form.setAttribute("selected-state-key", "selectedPayerAddress");
+ form.dataset.errorGenericSave = "Generic error";
+ form.dataset.fieldRequiredSymbol = "*";
+ form.dataset.nextButtonLabel = "Next";
+ await form.promiseReady;
+ form.form.dataset.extraRequiredFields = "name email tel";
+ display.appendChild(form);
+ await form.requestStore.setState({
+ page: {
+ id: "payer-address-page",
+ },
+ "payer-address-page": {
+ addressFields: "name email tel",
+ },
+ });
+ await asyncElementRendered();
+
+ ok(form.saveButton.disabled, "Save button should be disabled due to empty fields");
+
+ ok(!isHidden(form.form.querySelector("#given-name")),
+ "given-name should be visible");
+ ok(!isHidden(form.form.querySelector("#additional-name")),
+ "additional-name should be visible");
+ ok(!isHidden(form.form.querySelector("#family-name")),
+ "family-name should be visible");
+ ok(isHidden(form.form.querySelector("#organization")),
+ "organization should be hidden");
+ ok(isHidden(form.form.querySelector("#street-address")),
+ "street-address should be hidden");
+ ok(isHidden(form.form.querySelector("#address-level2")),
+ "address-level2 should be hidden");
+ ok(isHidden(form.form.querySelector("#address-level1")),
+ "address-level1 should be hidden");
+ ok(isHidden(form.form.querySelector("#postal-code")),
+ "postal-code should be hidden");
+ ok(isHidden(form.form.querySelector("#country")),
+ "country should be hidden");
+ ok(!isHidden(form.form.querySelector("#email")),
+ "email should be visible");
+ let telField = form.form.querySelector("#tel");
+ ok(!isHidden(telField),
+ "tel should be visible");
+ let telContainer = telField.closest(`#${telField.id}-container`);
+ ok(telContainer.hasAttribute("required"), "tel container should have required attribute");
+ let telSpan = telContainer.querySelector("span");
+ is(telSpan.getAttribute("fieldRequiredSymbol"), "*",
+ "tel span should have asterisk as fieldRequiredSymbol");
+ is(getComputedStyle(telSpan, "::after").content, "attr(fieldRequiredSymbol)",
+ "Asterisk should be on tel");
+
+ fillField(form.form.querySelector("#given-name"), "John");
+ fillField(form.form.querySelector("#family-name"), "Smith");
+ ok(form.saveButton.disabled, "Save button should be disabled due to empty fields");
+ fillField(form.form.querySelector("#email"), "john@example.com");
+ ok(form.saveButton.disabled,
+ "Save button should be disabled due to empty fields");
+ fillField(form.form.querySelector("#tel"), "+15555555555");
+ ok(!form.saveButton.disabled, "Save button should be enabled with all required fields filled");
+
+ form.remove();
+ await form.requestStore.setState({
+ "payer-address-page": {},
+ });
+});
+
+add_task(async function test_field_validation() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ form.dataset.fieldRequiredSymbol = "*";
+ await form.promiseReady;
+ display.appendChild(form);
+ await form.requestStore.setState({
+ page: {
+ id: "shipping-address-page",
+ },
+ });
+ await asyncElementRendered();
+
+ let postalCodeInput = form.form.querySelector("#postal-code");
+ let addressLevel1Input = form.form.querySelector("#address-level1");
+ ok(!postalCodeInput.value, "postal-code should be empty by default");
+ ok(!addressLevel1Input.value, "address-level1 should be empty by default");
+ ok(!postalCodeInput.checkValidity(), "postal-code should be invalid by default");
+ ok(!addressLevel1Input.checkValidity(), "address-level1 should be invalid by default");
+
+ let countrySelect = form.form.querySelector("#country");
+ let requiredFields = [
+ form.form.querySelector("#given-name"),
+ form.form.querySelector("#street-address"),
+ form.form.querySelector("#address-level2"),
+ postalCodeInput,
+ addressLevel1Input,
+ countrySelect,
+ ];
+ for (let field of requiredFields) {
+ let container = field.closest(`#${field.id}-container`);
+ ok(container.hasAttribute("required"), `#${field.id} container should have required attribute`);
+ let span = container.querySelector("span");
+ is(span.getAttribute("fieldRequiredSymbol"), "*",
+ "span should have asterisk as fieldRequiredSymbol");
+ is(getComputedStyle(span, "::after").content, "attr(fieldRequiredSymbol)",
+ "Asterisk should be on " + field.id);
+ }
+
+ ok(form.saveButton.disabled, "Save button should be disabled upon load");
+
+ fillField(countrySelect, "US");
+
+ sendStringAndCheckValidity(addressLevel1Input, "MI", true);
+ sendStringAndCheckValidity(addressLevel1Input, "", false);
+ sendStringAndCheckValidity(postalCodeInput, "B4N4N4", false);
+ sendStringAndCheckValidity(addressLevel1Input, "NS", false);
+ sendStringAndCheckValidity(postalCodeInput, "R3J 3C7", false);
+ sendStringAndCheckValidity(addressLevel1Input, "", false);
+ sendStringAndCheckValidity(postalCodeInput, "11109", true);
+ sendStringAndCheckValidity(addressLevel1Input, "NS", false);
+ sendStringAndCheckValidity(postalCodeInput, "06390-0001", true);
+
+ fillField(countrySelect, "CA");
+
+ sendStringAndCheckValidity(postalCodeInput, "00001", false);
+ sendStringAndCheckValidity(addressLevel1Input, "CA", false);
+ sendStringAndCheckValidity(postalCodeInput, "94043", false);
+ sendStringAndCheckValidity(addressLevel1Input, "", false);
+ sendStringAndCheckValidity(postalCodeInput, "B4N4N4", true);
+ sendStringAndCheckValidity(addressLevel1Input, "MI", false);
+ sendStringAndCheckValidity(postalCodeInput, "R3J 3C7", true);
+ sendStringAndCheckValidity(addressLevel1Input, "", false);
+ sendStringAndCheckValidity(postalCodeInput, "11109", false);
+ sendStringAndCheckValidity(addressLevel1Input, "NS", true);
+ sendStringAndCheckValidity(postalCodeInput, "06390-0001", false);
+
+ form.remove();
+});
+
+add_task(async function test_merchantShippingAddressErrors() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+
+ // Merchant errors only make sense when editing a record so add one.
+ let address1 = deepClone(PTU.Addresses.TimBR);
+ address1.guid = "9864798564";
+
+ const state = {
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ guid: address1.guid,
+ },
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {
+ addressLine: "Street address needs to start with a D",
+ city: "City needs to start with a B",
+ country: "Country needs to start with a C",
+ dependentLocality: "Can only be SUBURBS, not NEIGHBORHOODS",
+ organization: "organization needs to start with an A",
+ phone: "Telephone needs to start with a 9",
+ postalCode: "Postal code needs to start with a 0",
+ recipient: "Name needs to start with a Z",
+ region: "Region needs to start with a Y",
+ regionCode: "Regions must be 1 to 3 characters in length (sometimes ;) )",
+ },
+ },
+ paymentOptions: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ };
+ display.appendChild(form);
+ await form.requestStore.setState(state);
+ await asyncElementRendered();
+
+ function checkValidationMessage(selector, property) {
+ let expected = state.request.paymentDetails.shippingAddressErrors[property];
+ let container = form.form.querySelector(selector + "-container");
+ ok(!isHidden(container), selector + "-container should be visible");
+ is(form.form.querySelector(selector).validationMessage,
+ expected,
+ "Validation message should match for " + selector);
+ }
+
+ ok(form.saveButton.disabled, "Save button should be disabled due to validation errors");
+
+ checkValidationMessage("#street-address", "addressLine");
+ checkValidationMessage("#address-level2", "city");
+ checkValidationMessage("#address-level3", "dependentLocality");
+ checkValidationMessage("#country", "country");
+ checkValidationMessage("#organization", "organization");
+ checkValidationMessage("#tel", "phone");
+ checkValidationMessage("#postal-code", "postalCode");
+ checkValidationMessage("#given-name", "recipient");
+ checkValidationMessage("#address-level1", "regionCode");
+ isnot(form.form.querySelector("#address-level1"),
+ state.request.paymentDetails.shippingAddressErrors.region,
+ "When both region and regionCode are supplied we only show the 'regionCode' error");
+
+ // TODO: bug 1482808 - the save button should be enabled after editing the fields
+
+ form.remove();
+});
+
+add_task(async function test_customMerchantValidity_reset() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+
+ // Merchant errors only make sense when editing a record so add one.
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ const state = {
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ guid: address1.guid,
+ },
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {
+ addressLine: "Street address needs to start with a D",
+ city: "City needs to start with a B",
+ country: "Country needs to start with a C",
+ organization: "organization needs to start with an A",
+ phone: "Telephone needs to start with a 9",
+ postalCode: "Postal code needs to start with a 0",
+ recipient: "Name needs to start with a Z",
+ region: "Region needs to start with a Y",
+ },
+ },
+ paymentOptions: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ };
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ ok(!!form.querySelectorAll(":-moz-ui-invalid").length, "Check fields are visibly invalid");
+ info("merchant cleared the errors");
+ await form.requestStore.setState({
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {},
+ },
+ paymentOptions: {},
+ },
+ });
+ await asyncElementRendered();
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check fields are visibly valid - custom validity cleared");
+
+ form.remove();
+});
+
+add_task(async function test_customMerchantValidity_shippingAddressForm() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+
+ // Merchant errors only make sense when editing a record so add one.
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ const state = {
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ guid: address1.guid,
+ },
+ request: {
+ paymentDetails: {
+ billingAddressErrors: {
+ addressLine: "Billing Street address needs to start with a D",
+ city: "Billing City needs to start with a B",
+ country: "Billing Country needs to start with a C",
+ organization: "Billing organization needs to start with an A",
+ phone: "Billing Telephone needs to start with a 9",
+ postalCode: "Billing Postal code needs to start with a 0",
+ recipient: "Billing Name needs to start with a Z",
+ region: "Billing Region needs to start with a Y",
+ },
+ },
+ paymentOptions: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ };
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check fields are visibly valid - billing errors are not relevant to a shipping address form");
+
+ // now switch in some shipping address errors
+ await form.requestStore.setState({
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {
+ addressLine: "Street address needs to start with a D",
+ city: "City needs to start with a B",
+ country: "Country needs to start with a C",
+ organization: "organization needs to start with an A",
+ phone: "Telephone needs to start with a 9",
+ postalCode: "Postal code needs to start with a 0",
+ recipient: "Name needs to start with a Z",
+ region: "Region needs to start with a Y",
+ },
+ },
+ paymentOptions: {},
+ },
+ });
+ await asyncElementRendered();
+
+ ok(form.querySelectorAll(":-moz-ui-invalid").length >= 8, "Check fields are visibly invalid");
+});
+
+add_task(async function test_customMerchantValidity_billingAddressForm() {
+ let form = new AddressForm();
+ form.id = "billing-address-page";
+ form.setAttribute("selected-state-key", "basic-card-page|billingAddressGUID");
+ await form.promiseReady;
+
+ // Merchant errors only make sense when editing a record so add one.
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ const state = {
+ page: {
+ id: "billing-address-page",
+ },
+ "billing-address-page": {
+ guid: address1.guid,
+ },
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {
+ addressLine: "Street address needs to start with a D",
+ city: "City needs to start with a B",
+ country: "Country needs to start with a C",
+ organization: "organization needs to start with an A",
+ phone: "Telephone needs to start with a 9",
+ postalCode: "Postal code needs to start with a 0",
+ recipient: "Name needs to start with a Z",
+ region: "Region needs to start with a Y",
+ },
+ },
+ paymentOptions: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ };
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check fields are visibly valid - shipping errors are not relevant to a billing address form");
+
+ await form.requestStore.setState({
+ request: {
+ paymentDetails: {
+ paymentMethodErrors: {
+ billingAddress: {
+ addressLine: "Billing Street address needs to start with a D",
+ city: "Billing City needs to start with a B",
+ country: "Billing Country needs to start with a C",
+ organization: "Billing organization needs to start with an A",
+ phone: "Billing Telephone needs to start with a 9",
+ postalCode: "Billing Postal code needs to start with a 0",
+ recipient: "Billing Name needs to start with a Z",
+ region: "Billing Region needs to start with a Y",
+ },
+ },
+ },
+ paymentOptions: {},
+ },
+ });
+ await asyncElementRendered();
+ ok(form.querySelectorAll(":-moz-ui-invalid").length >= 8,
+ "Check billing fields are visibly invalid");
+
+ form.remove();
+});
+
+add_task(async function test_merchantPayerAddressErrors() {
+ let form = new AddressForm();
+ form.id = "payer-address-page";
+ form.setAttribute("selected-state-key", "selectedPayerAddress");
+
+ await form.promiseReady;
+ form.form.dataset.extraRequiredFields = "name email tel";
+
+ // Merchant errors only make sense when editing a record so add one.
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ const state = {
+ page: {
+ id: "payer-address-page",
+ },
+ "payer-address-page": {
+ addressFields: "name email tel",
+ guid: address1.guid,
+ },
+ request: {
+ paymentDetails: {
+ payerErrors: {
+ email: "Email must be @mozilla.org",
+ name: "Name needs to start with a W",
+ phone: "Telephone needs to start with a 1",
+ },
+ },
+ paymentOptions: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ };
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ function checkValidationMessage(selector, property) {
+ is(form.form.querySelector(selector).validationMessage,
+ state.request.paymentDetails.payerErrors[property],
+ "Validation message should match for " + selector);
+ }
+
+ ok(form.saveButton.disabled, "Save button should be disabled due to validation errors");
+
+ checkValidationMessage("#tel", "phone");
+ checkValidationMessage("#family-name", "name");
+ checkValidationMessage("#email", "email");
+
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 3, "Check payer fields are visibly invalid");
+
+ await form.requestStore.setState({
+ request: {
+ paymentDetails: {
+ payerErrors: {},
+ },
+ paymentOptions: {},
+ },
+ });
+ await asyncElementRendered();
+
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check payer fields are visibly valid after clearing merchant errors");
+
+ form.remove();
+});
+
+add_task(async function test_field_validation() {
+ let getFormFormatStub = sinon.stub(PaymentDialogUtils, "getFormFormat");
+ getFormFormatStub.returns({
+ addressLevel1Label: "state",
+ postalCodeLabel: "US",
+ fieldsOrder: [
+ {fieldId: "name", newLine: true},
+ {fieldId: "organization", newLine: true},
+ {fieldId: "street-address", newLine: true},
+ {fieldId: "address-level2"},
+ ],
+ });
+
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+ const state = {
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ },
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {},
+ },
+ paymentOptions: {},
+ },
+ };
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ ok(form.saveButton.disabled, "Save button should be disabled due to empty fields");
+
+ let postalCodeInput = form.form.querySelector("#postal-code");
+ let addressLevel1Input = form.form.querySelector("#address-level1");
+ ok(!postalCodeInput.value, "postal-code should be empty by default");
+ ok(!addressLevel1Input.value, "address-level1 should be empty by default");
+ ok(postalCodeInput.checkValidity(),
+ "postal-code should be valid by default when it is not visible");
+ ok(addressLevel1Input.checkValidity(),
+ "address-level1 should be valid by default when it is not visible");
+
+ getFormFormatStub.restore();
+ form.remove();
+});
+
+add_task(async function test_field_validation_dom_popup() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+ const state = {
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ },
+ };
+
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ const BAD_POSTAL_CODE = "hi mom";
+ let postalCode = form.querySelector("#postal-code");
+ postalCode.focus();
+ sendString(BAD_POSTAL_CODE, window);
+ postalCode.blur();
+ let errorTextSpan = postalCode.parentNode.querySelector(".error-text");
+ is(errorTextSpan.textContent, "Please match the requested format.",
+ "DOM validation messages should be reflected in the error-text #1");
+
+ postalCode.focus();
+ while (postalCode.value) {
+ sendKey("BACK_SPACE", window);
+ }
+ postalCode.blur();
+ is(errorTextSpan.textContent, "Please fill out this field.",
+ "DOM validation messages should be reflected in the error-text #2");
+
+ postalCode.focus();
+ sendString("12345", window);
+ is(errorTextSpan.innerText, "", "DOM validation message should be removed when no error");
+ postalCode.blur();
+
+ form.remove();
+});
+
+add_task(async function test_hiddenMailingAddressFieldsCleared() {
+ let form = new AddressForm();
+ form.id = "address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ form.dataset.updateButtonLabel = "Update";
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ await form.requestStore.setState({
+ page: {
+ id: "address-page",
+ },
+ "address-page": {
+ guid: address1.guid,
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ });
+ await asyncElementRendered();
+
+ info("Change the country to hide address-level1");
+ fillField(form.form.querySelector("#country"), "DE");
+
+ let expectedRecord = Object.assign({}, address1, {
+ country: "DE",
+ // address-level1 & 3 aren't used for Germany so should be blanked.
+ "address-level1": "",
+ "address-level3": "",
+ });
+ delete expectedRecord.guid;
+ // The following were not shown so shouldn't be part of the message:
+ delete expectedRecord.email;
+
+ let messagePromise = promiseContentToChromeMessage("updateAutofillRecord");
+ form.saveButton.scrollIntoView();
+ synthesizeMouseAtCenter(form.saveButton, {});
+
+ info("Waiting for messagePromise");
+ let details = await messagePromise;
+ info("/Waiting for messagePromise");
+ delete details.messageID;
+ is(details.collectionName, "addresses", "Check collectionName");
+ isDeeply(details, {
+ collectionName: "addresses",
+ guid: address1.guid,
+ messageType: "updateAutofillRecord",
+ record: expectedRecord,
+ }, "Check update event details for the message to chrome");
+
+ form.remove();
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_address_option.html b/browser/components/payments/test/mochitest/test_address_option.html
new file mode 100644
index 0000000000..670208a775
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_address_option.html
@@ -0,0 +1,177 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the address-option component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the address-option component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <option id="option1"
+ data-field-separator=", "
+ address-level1="MI"
+ address-level2="Some City"
+ country="US"
+ email="foo@bar.com"
+ name="John Smith"
+ postal-code="90210"
+ street-address="123 Sesame Street,&#xA;Apt 40"
+ tel="+1 519 555-5555"
+ value="option1"
+ guid="option1"></option>
+ <option id="option2"
+ data-field-separator=", "
+ value="option2"
+ guid="option2"></option>
+
+ <rich-select id="richSelect1"
+ option-type="address-option"></rich-select>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the address-option component **/
+
+import "../../res/components/address-option.js";
+import "../../res/components/rich-select.js";
+
+let option1 = document.getElementById("option1");
+let option2 = document.getElementById("option2");
+let richSelect1 = document.getElementById("richSelect1");
+
+add_task(async function test_populated_option_rendering() {
+ richSelect1.popupBox.appendChild(option1);
+ richSelect1.value = option1.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+
+ is(richOption.name, "John Smith", "Check name getter");
+ is(richOption.streetAddress, "123 Sesame Street,\nApt 40", "Check streetAddress getter");
+ is(richOption.addressLevel2, "Some City", "Check addressLevel2 getter");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+ ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+ is(richOption._line1.innerText, "John Smith, 123 Sesame Street, Apt 40", "Line 1 text");
+ is(richOption._line2.innerText, "Some City, MI, 90210, US", "Line 2 text");
+
+ // Note that innerText takes visibility into account so that's why it's used over textContent here
+ is(richOption._name.innerText, "John Smith", "name text");
+ is(richOption["_street-address"].innerText, "123 Sesame Street, Apt 40", "street-address text");
+ is(richOption["_address-level2"].innerText, "Some City", "address-level2 text");
+
+ is(richOption._email.parentElement, null,
+ "Check email field isn't in the document for a mailing-address option");
+});
+
+// Same option as the last test but with @break-after-nth-field=1
+add_task(async function test_breakAfterNthField() {
+ richSelect1.popupBox.appendChild(option1);
+ richSelect1.value = option1.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ richOption.breakAfterNthField = 1;
+ await asyncElementRendered();
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+ ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+ is(richOption._line1.innerText, "John Smith", "Line 1 text with breakAfterNthField = 1");
+ is(richOption._line2.innerText, "123 Sesame Street, Apt 40, Some City, MI, 90210, US",
+ "Line 2 text with breakAfterNthField = 1");
+});
+
+add_task(async function test_addressField_mailingAddress() {
+ richSelect1.popupBox.appendChild(option1);
+ richSelect1.value = option1.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ richOption.addressFields = "mailing-address";
+ await asyncElementRendered();
+ is(richOption.getAttribute("address-fields"), "mailing-address", "Check @address-fields");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+ ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+ is(richOption._line1.innerText, "John Smith, 123 Sesame Street, Apt 40", "Line 1 text");
+ is(richOption._line2.innerText, "Some City, MI, 90210, US", "Line 2 text");
+
+ ok(!isHidden(richOption._line2), "Line 2 should be visible when it's used");
+
+ is(richOption._email.parentElement, null,
+ "Check email field isn't in the document for a mailing-address option");
+});
+
+add_task(async function test_addressField_nameEmail() {
+ richSelect1.popupBox.appendChild(option1);
+ richSelect1.value = option1.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ richOption.addressFields = "name email";
+ await asyncElementRendered();
+ is(richOption.getAttribute("address-fields"), "name email", "Check @address-fields");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+ ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+ is(richOption._line1.innerText, "John Smith, foo@bar.com", "Line 1 text");
+ is(richOption._line2.innerText, "", "Line 2 text");
+
+ ok(isHidden(richOption._line2), "Line 2 should be hidden when it's not used");
+
+ isnot(richOption._email.parentElement, null,
+ "Check email field is in the document for a 'name email' option");
+});
+
+add_task(async function test_missing_fields_option_rendering() {
+ richSelect1.popupBox.appendChild(option2);
+ richSelect1.value = option2.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ is(richOption.name, null, "Check name getter");
+ is(richOption.streetAddress, null, "Check streetAddress getter");
+ is(richOption.addressLevel2, null, "Check addressLevel2 getter");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ is(richOption._name.innerText, "", "name text");
+ is(window.getComputedStyle(richOption._name, "::before").content, "attr(data-missing-string)",
+ "Check missing field pseudo content");
+ is(richOption._name.getAttribute("data-missing-string"), "Name Missing",
+ "Check @data-missing-string");
+ is(richOption._email.parentElement, null,
+ "Check email field isn't in the document for a mailing-address option");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_address_picker.html b/browser/components/payments/test/mochitest/test_address_picker.html
new file mode 100644
index 0000000000..5a3f1b398a
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_address_picker.html
@@ -0,0 +1,278 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the address-picker component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the address-picker component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <address-picker id="picker1"
+ data-field-separator=", "
+ data-invalid-label="Picker1: Missing or Invalid"
+ selected-state-key="selectedShippingAddress"></address-picker>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the address-picker component **/
+
+import "../../res/containers/address-picker.js";
+
+let picker1 = document.getElementById("picker1");
+
+add_task(async function test_empty() {
+ ok(picker1, "Check picker1 exists");
+ let {savedAddresses} = picker1.requestStore.getState();
+ is(Object.keys(savedAddresses).length, 0, "Check empty initial state");
+ is(picker1.editLink.hidden, true, "Check that picker edit link is hidden");
+ is(picker1.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
+});
+
+add_task(async function test_initialSet() {
+ picker1.requestStore.setState({
+ savedAddresses: {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ "country": "US",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ "tel": "+1 519 555-5555",
+ timeLastUsed: 200,
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ timeLastUsed: 300,
+ },
+ "abcde12345": {
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "abcde12345",
+ "name": "Mrs. Fields",
+ timeLastUsed: 100,
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 3, "Check dropdown has all addresses");
+ ok(options[0].textContent.includes("Mrs. Bar"), "Check first address based on timeLastUsed");
+ ok(options[1].textContent.includes("Mr. Foo"), "Check second address based on timeLastUsed");
+ ok(options[2].textContent.includes("Mrs. Fields"), "Check third address based on timeLastUsed");
+});
+
+add_task(async function test_update() {
+ picker1.requestStore.setState({
+ savedAddresses: {
+ "48bnds6854t": {
+ // Same GUID, different values to trigger an update
+ "address-level1": "MI-edit",
+ // address-level2 was cleared which means it's not returned
+ "country": "CA",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo-edit",
+ "postal-code": "90210-1234",
+ "street-address": "new-edit",
+ "tel": "+1 650 555-5555",
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ },
+ "abcde12345": {
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "abcde12345",
+ "name": "Mrs. Fields",
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 3, "Check dropdown still has all addresses");
+ ok(options[0].textContent.includes("Mr. Foo-edit"), "Check updated name in first address");
+ ok(!options[0].getAttribute("address-level2"), "Check removed first address-level2");
+ ok(options[1].textContent.includes("Mrs. Bar"), "Check that name is the same in second address");
+ ok(options[1].getAttribute("street-address").includes("P.O. Box 123"),
+ "Check second address is the same");
+ ok(options[2].textContent.includes("Mrs. Fields"),
+ "Check that name is the same in third address");
+ is(options[2].getAttribute("street-address"), null, "Check third address is missing");
+});
+
+add_task(async function test_change_selected_address() {
+ let options = picker1.dropdown.popupBox.children;
+ is(picker1.dropdown.selectedOption, null, "Should default to no selected option");
+ is(picker1.editLink.hidden, true, "Picker edit link should be hidden when no option is selected");
+ let {selectedShippingAddress} = picker1.requestStore.getState();
+ is(selectedShippingAddress, null, "store should have no option selected");
+ ok(!picker1.classList.contains("invalid-selected-option"), "No validation on an empty selection");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey(options[2].getAttribute("name"), {});
+ await asyncElementRendered();
+
+ let selectedOption = picker1.dropdown.selectedOption;
+ is(selectedOption, options[2], "Selected option should now be the third option");
+ selectedShippingAddress = picker1.requestStore.getState().selectedShippingAddress;
+ is(selectedShippingAddress, selectedOption.getAttribute("guid"),
+ "store should have third option selected");
+ // The third option is missing some fields. Make sure that it is marked as such.
+ ok(picker1.classList.contains("invalid-selected-option"), "The third option is missing fields");
+ ok(!isHidden(picker1.invalidLabel), "The invalid label should be visible");
+ is(picker1.invalidLabel.innerText, picker1.dataset.invalidLabel, "Check displayed error text");
+
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey(options[1].getAttribute("name"), {});
+ await asyncElementRendered();
+
+ selectedOption = picker1.dropdown.selectedOption;
+ is(selectedOption, options[1], "Selected option should now be the second option");
+ selectedShippingAddress = picker1.requestStore.getState().selectedShippingAddress;
+ is(selectedShippingAddress, selectedOption.getAttribute("guid"),
+ "store should have second option selected");
+ ok(!picker1.classList.contains("invalid-selected-option"), "The second option has all fields");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+});
+
+add_task(async function test_address_combines_name_street_level2_level1_postalCode_country() {
+ let options = picker1.dropdown.popupBox.children;
+ let richoption1 = picker1.dropdown.querySelector(".rich-select-selected-option");
+ /* eslint-disable max-len */
+ is(richoption1.innerText,
+ `${options[1].getAttribute("name")}, ${options[1].getAttribute("street-address")}
+${options[1].getAttribute("address-level2")}, ${options[1].getAttribute("address-level1")}, ${options[1].getAttribute("postal-code")}, ${options[1].getAttribute("country")}`,
+ "The address shown should be human readable and include all fields");
+ /* eslint-enable max-len */
+
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey(options[2].getAttribute("name"), {});
+ await asyncElementRendered();
+
+ richoption1 = picker1.dropdown.querySelector(".rich-select-selected-option");
+ // "Missing …" text is rendered via a pseudo element content and isn't included in innerText
+ is(richoption1.innerText, "Mrs. Fields, \nMountain View, , US",
+ "The address shown should be human readable and include all fields");
+
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey(options[1].getAttribute("name"), {});
+ await asyncElementRendered();
+});
+
+add_task(async function test_delete() {
+ picker1.requestStore.setState({
+ savedAddresses: {
+ // 48bnds6854t and abcde12345 was deleted
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 1, "Check dropdown has one remaining address");
+ ok(options[0].textContent.includes("Mrs. Bar"), "Check remaining address");
+});
+
+add_task(async function test_merchantError() {
+ picker1.requestStore.setState({
+ selectedShippingAddress: "68gjdh354j",
+ });
+ await asyncElementRendered();
+
+ is(picker1.selectedStateKey, "selectedShippingAddress", "Check selectedStateKey");
+
+ let state = picker1.requestStore.getState();
+ let {
+ request,
+ } = state;
+ ok(!picker1.classList.contains("invalid-selected-option"), "No validation on a valid option");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+
+ let requestWithShippingAddressErrors = deepClone(request);
+ Object.assign(requestWithShippingAddressErrors.paymentDetails, {
+ shippingAddressErrors: {
+ country: "Your country is not supported",
+ },
+ });
+ picker1.requestStore.setState({
+ request: requestWithShippingAddressErrors,
+ });
+ await asyncElementRendered();
+
+ ok(picker1.classList.contains("invalid-selected-option"), "The merchant error applies");
+ ok(!isHidden(picker1.invalidLabel), "The merchant error should be visible");
+ is(picker1.invalidLabel.innerText, "Your country is not supported", "Check displayed error text");
+
+ info("update the request to remove the errors");
+ picker1.requestStore.setState({
+ request,
+ });
+ await asyncElementRendered();
+ ok(!picker1.classList.contains("invalid-selected-option"),
+ "No errors visible when merchant errors cleared");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+
+ info("Set billing address and payer errors which aren't relevant to this picker");
+ let requestWithNonShippingAddressErrors = deepClone(request);
+ Object.assign(requestWithNonShippingAddressErrors.paymentDetails, {
+ payerErrors: {
+ name: "Your name is too short",
+ },
+ paymentMethodErrors: {
+ billingAddress: {
+ country: "Your billing country is not supported",
+ },
+ },
+ shippingAddressErrors: {},
+ });
+ picker1.requestStore.setState({
+ request: requestWithNonShippingAddressErrors,
+ });
+ await asyncElementRendered();
+ ok(!picker1.classList.contains("invalid-selected-option"), "No errors on a shipping picker");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should still be hidden");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_basic_card_form.html b/browser/components/payments/test/mochitest/test_basic_card_form.html
new file mode 100644
index 0000000000..239e3b013d
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_basic_card_form.html
@@ -0,0 +1,623 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the basic-card-form element
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the basic-card-form element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/accepted-cards.css"/>
+</head>
+<body>
+ <p id="display" style="height: 100vh; margin: 0;">
+ <iframe id="templateFrame" src="paymentRequest.xhtml" width="0" height="0"
+ sandbox="allow-same-origin"
+ style="float: left;"></iframe>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the basic-card-form element **/
+
+import BasicCardForm from "../../res/containers/basic-card-form.js";
+
+let display = document.getElementById("display");
+let supportedNetworks = ["discover", "amex"];
+let paymentMethods = [{
+ supportedMethods: "basic-card",
+ data: {
+ supportedNetworks,
+ },
+}];
+
+function checkCCForm(customEl, expectedCard) {
+ const CC_PROPERTY_NAMES = [
+ "billingAddressGUID",
+ "cc-number",
+ "cc-name",
+ "cc-exp-month",
+ "cc-exp-year",
+ "cc-type",
+ ];
+ for (let propName of CC_PROPERTY_NAMES) {
+ let expectedVal = expectedCard[propName] || "";
+ is(document.getElementById(propName).value,
+ expectedVal.toString(),
+ `Check ${propName}`);
+ }
+}
+
+function createAddressRecord(source, props = {}) {
+ let address = Object.assign({}, source, props);
+ if (!address.name) {
+ address.name = `${address["given-name"]} ${address["family-name"]}`;
+ }
+ return address;
+}
+
+add_task(async function setup_once() {
+ let templateFrame = document.getElementById("templateFrame");
+ await SimpleTest.promiseFocus(templateFrame.contentWindow);
+ let displayEl = document.getElementById("display");
+ importDialogDependencies(templateFrame, displayEl);
+});
+
+add_task(async function test_initialState() {
+ let form = new BasicCardForm();
+
+ await form.requestStore.setState({
+ savedAddresses: {
+ "TimBLGUID": createAddressRecord(PTU.Addresses.TimBL),
+ },
+ });
+
+ let {page} = form.requestStore.getState();
+ is(page.id, "payment-summary", "Check initial page");
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+ is(page.id, "payment-summary", "Check initial page after appending");
+
+ // :-moz-ui-invalid, unlike :invalid, only applies to fields showing the error outline.
+ let fieldsVisiblyInvalid = form.querySelectorAll(":-moz-ui-invalid");
+ for (let field of fieldsVisiblyInvalid) {
+ info("invalid field: " + field.localName + "#" + field.id + "." + field.className);
+ }
+ is(fieldsVisiblyInvalid.length, 0, "Check no fields are visibly invalid on an empty 'add' form");
+
+ form.remove();
+});
+
+add_task(async function test_backButton() {
+ let form = new BasicCardForm();
+ form.dataset.backButtonLabel = "Back";
+ form.dataset.addBasicCardTitle = "Sample page title 2";
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ selectedStateKey: "selectedPaymentCard",
+ },
+ });
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ let stateChangePromise = promiseStateChange(form.requestStore);
+ is(form.pageTitleHeading.textContent, "Sample page title 2", "Check title");
+ is(form.backButton.textContent, "Back", "Check label");
+ form.backButton.scrollIntoView();
+ 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 = new BasicCardForm();
+ form.dataset.nextButtonLabel = "Next";
+ form.dataset.errorGenericSave = "Generic error";
+ form.dataset.invalidAddressLabel = "Invalid";
+
+ await form.promiseReady;
+ display.appendChild(form);
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, {guid: "TimBLGUID"});
+ let address2 = createAddressRecord(PTU.Addresses.TimBL2, {guid: "TimBL2GUID"});
+
+ await form.requestStore.setState({
+ request: {
+ paymentMethods,
+ paymentDetails: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ [address2.guid]: deepClone(address2),
+ },
+ });
+
+ await asyncElementRendered();
+
+ // when merchant provides supportedNetworks, the accepted card list should be visible
+ ok(!form.acceptedCardsList.hidden, "Accepted card list should be visible when adding a card");
+
+ ok(form.saveButton.disabled, "Save button should initially be disabled");
+ fillField(form.form.querySelector("#cc-number"), "4111 1111-1111 1111");
+ form.form.querySelector("#cc-name").focus();
+ // Check .disabled after .focus() so that it's after both "input" and "change" events.
+ ok(form.saveButton.disabled, "Save button should still be disabled without a name");
+ sendString("J. Smith");
+ fillField(form.form.querySelector("#cc-exp-month"), "11");
+ let year = (new Date()).getFullYear().toString();
+ fillField(form.form.querySelector("#cc-exp-year"), year);
+ fillField(form.form.querySelector("#cc-type"), "visa");
+ fillField(form.form.querySelector("csc-input input"), "123");
+ isnot(form.form.querySelector("#billingAddressGUID").value, address2.guid,
+ "Check initial billing address");
+ fillField(form.form.querySelector("#billingAddressGUID"), address2.guid);
+ is(form.form.querySelector("#billingAddressGUID").value, address2.guid,
+ "Check selected billing address");
+ form.saveButton.focus();
+ ok(!form.saveButton.disabled,
+ "Save button should be enabled since the required fields are filled");
+
+ fillField(form.form.querySelector("#cc-exp-month"), "");
+ fillField(form.form.querySelector("#cc-exp-year"), "");
+ form.saveButton.focus();
+ ok(form.saveButton.disabled,
+ "Save button should be disabled since the required fields are empty");
+ fillField(form.form.querySelector("#cc-exp-month"), "11");
+ fillField(form.form.querySelector("#cc-exp-year"), year);
+ form.saveButton.focus();
+ ok(!form.saveButton.disabled,
+ "Save button should be enabled since the required fields are filled again");
+
+ info("blanking the cc-number field");
+ fillField(form.form.querySelector("#cc-number"), "");
+ ok(form.saveButton.disabled, "Save button is disabled after blanking cc-number");
+ form.form.querySelector("#cc-number").blur();
+ let fieldsVisiblyInvalid = form.querySelectorAll(":-moz-ui-invalid");
+ is(fieldsVisiblyInvalid.length, 1, "Check 1 field visibly invalid after blanking and blur");
+ is(fieldsVisiblyInvalid[0].id, "cc-number", "Check #cc-number is visibly invalid");
+
+ fillField(form.form.querySelector("#cc-number"), "4111 1111-1111 1111");
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0, "Check no fields visibly invalid");
+ ok(!form.saveButton.disabled, "Save button is enabled after re-filling cc-number");
+
+ let messagePromise = promiseContentToChromeMessage("updateAutofillRecord");
+ is(form.saveButton.textContent, "Next", "Check label");
+ form.saveButton.scrollIntoView();
+ synthesizeMouseAtCenter(form.saveButton, {});
+
+ let details = await messagePromise;
+ ok(typeof(details.messageID) == "number" && details.messageID > 0, "Check messageID type");
+ delete details.messageID;
+ is(details.collectionName, "creditCards", "Check collectionName");
+ isDeeply(details, {
+ collectionName: "creditCards",
+ guid: undefined,
+ messageType: "updateAutofillRecord",
+ record: {
+ "cc-exp-month": "11",
+ "cc-exp-year": year,
+ "cc-name": "J. Smith",
+ "cc-number": "4111 1111-1111 1111",
+ "cc-type": "visa",
+ "billingAddressGUID": address2.guid,
+ "isTemporary": true,
+ },
+ }, "Check event details for the message to chrome");
+ form.remove();
+});
+
+add_task(async function test_requiredAttributePropagated() {
+ let form = new BasicCardForm();
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ let requiredElements = [...form.form.elements].filter(e => e.required && !e.disabled);
+ is(requiredElements.length, 7, "Number of required elements");
+ for (let element of requiredElements) {
+ if (element.id == "billingAddressGUID") {
+ // The billing address has a different layout.
+ continue;
+ }
+ let container = element.closest("label") || element.closest("div");
+ ok(container.hasAttribute("required"),
+ `Container ${container.id} should also be marked as required`);
+ }
+ // Now test that toggling the `required` attribute will affect the container.
+ let sampleRequiredElement = requiredElements[0];
+ let sampleRequiredContainer = sampleRequiredElement.closest("label") ||
+ sampleRequiredElement.closest("div");
+ sampleRequiredElement.removeAttribute("required");
+ await form.requestStore.setState({});
+ await asyncElementRendered();
+ ok(!sampleRequiredElement.hasAttribute("required"),
+ `"required" attribute should still be removed from element (${sampleRequiredElement.id})`);
+ ok(!sampleRequiredContainer.hasAttribute("required"),
+ `"required" attribute should be removed from container`);
+ sampleRequiredElement.setAttribute("required", "true");
+ await form.requestStore.setState({});
+ await asyncElementRendered();
+ ok(sampleRequiredContainer.hasAttribute("required"),
+ "`required` attribute is re-added to container");
+
+ form.remove();
+});
+
+add_task(async function test_genericError() {
+ let form = new BasicCardForm();
+ 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_add_selectedShippingAddress() {
+ let form = new BasicCardForm();
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ info("have an existing card in storage");
+ let card1 = deepClone(PTU.BasicCards.JohnDoe);
+ card1.guid = "9864798564";
+ card1["cc-exp-year"] = 2011;
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, { guid: "TimBLGUID" });
+ let address2 = createAddressRecord(PTU.Addresses.TimBL2, { guid: "TimBL2GUID" });
+
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ [address2.guid]: deepClone(address2),
+ },
+ savedBasicCards: {
+ [card1.guid]: deepClone(card1),
+ },
+ selectedShippingAddress: address2.guid,
+ });
+ await asyncElementRendered();
+ checkCCForm(form, {
+ billingAddressGUID: address2.guid,
+ });
+
+ form.remove();
+ await form.requestStore.reset();
+});
+
+add_task(async function test_add_noSelectedShippingAddress() {
+ let form = new BasicCardForm();
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ info("have an existing card in storage but unused");
+ let card1 = deepClone(PTU.BasicCards.JohnDoe);
+ card1.guid = "9864798564";
+ card1["cc-exp-year"] = 2011;
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, { guid: "TimBLGUID" });
+
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ savedBasicCards: {
+ [card1.guid]: deepClone(card1),
+ },
+ selectedShippingAddress: null,
+ });
+ await asyncElementRendered();
+ checkCCForm(form, {
+ billingAddressGUID: address1.guid,
+ });
+
+ info("now test with a missing selectedShippingAddress");
+ await form.requestStore.setState({
+ selectedShippingAddress: "some-missing-guid",
+ });
+ await asyncElementRendered();
+ checkCCForm(form, {
+ billingAddressGUID: address1.guid,
+ });
+
+ form.remove();
+ await form.requestStore.reset();
+});
+
+add_task(async function test_edit() {
+ let form = new BasicCardForm();
+ form.dataset.updateButtonLabel = "Update";
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, { guid: "TimBLGUID" });
+
+ info("test year before current");
+ let card1 = deepClone(PTU.BasicCards.JohnDoe);
+ card1.guid = "9864798564";
+ card1["cc-exp-year"] = 2011;
+ card1.billingAddressGUID = address1.guid;
+
+ await form.requestStore.setState({
+ request: {
+ paymentMethods,
+ paymentDetails: {},
+ },
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ guid: card1.guid,
+ selectedStateKey: "selectedPaymentCard",
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ savedBasicCards: {
+ [card1.guid]: deepClone(card1),
+ },
+ });
+ await asyncElementRendered();
+ is(form.saveButton.textContent, "Update", "Check label");
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check no fields are visibly invalid on an 'edit' form with a complete card");
+
+ checkCCForm(form, card1);
+ ok(!form.saveButton.disabled, "Save button should be enabled upon edit for a valid card");
+ ok(!form.acceptedCardsList.hidden, "Accepted card list should be visible when editing a card");
+
+ let requiredElements = [...form.form.elements].filter(e => e.required && !e.disabled);
+ ok(requiredElements.length, "There should be at least one required element");
+ is(requiredElements.length, 5, "Number of required elements");
+ for (let element of requiredElements) {
+ if (element.id == "billingAddressGUID") {
+ // The billing address has a different layout.
+ continue;
+ }
+
+ let container = element.closest("label") || element.closest("div");
+ ok(element.hasAttribute("required"), "Element should be marked as required");
+ ok(container.hasAttribute("required"), "Container should also be marked as required");
+ }
+
+ info("test future year");
+ card1["cc-exp-year"] = 2100;
+
+ await form.requestStore.setState({
+ savedBasicCards: {
+ [card1.guid]: deepClone(card1),
+ },
+ });
+ await asyncElementRendered();
+ checkCCForm(form, card1);
+
+ info("test change to minimal record");
+ let minimalCard = {
+ // no expiration date or name
+ "cc-number": "1234567690123",
+ guid: "9gnjdhen46",
+ };
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ guid: minimalCard.guid,
+ selectedStateKey: "selectedPaymentCard",
+ },
+ savedBasicCards: {
+ [minimalCard.guid]: deepClone(minimalCard),
+ },
+ });
+ await asyncElementRendered();
+ ok(!!form.querySelectorAll(":-moz-ui-invalid").length,
+ "Check fields are visibly invalid on an 'edit' form with missing fields");
+ checkCCForm(form, minimalCard);
+
+ info("change to no selected card");
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ guid: null,
+ selectedStateKey: "selectedPaymentCard",
+ },
+ });
+ await asyncElementRendered();
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check no fields are visibly invalid after converting to an 'add' form");
+ checkCCForm(form, {
+ billingAddressGUID: address1.guid, // Default selected
+ });
+
+ form.remove();
+});
+
+add_task(async function test_field_validity_updates() {
+ let form = new BasicCardForm();
+ form.dataset.updateButtonLabel = "Update";
+ await form.promiseReady;
+ display.appendChild(form);
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, {guid: "TimBLGUID"});
+ await form.requestStore.setState({
+ request: {
+ paymentMethods,
+ paymentDetails: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ });
+ await asyncElementRendered();
+
+ let ccNumber = form.form.querySelector("#cc-number");
+ let nameInput = form.form.querySelector("#cc-name");
+ let typeInput = form.form.querySelector("#cc-type");
+ let cscInput = form.form.querySelector("csc-input input");
+ let monthInput = form.form.querySelector("#cc-exp-month");
+ let yearInput = form.form.querySelector("#cc-exp-year");
+ let addressPicker = form.querySelector("#billingAddressGUID");
+
+ info("test with valid cc-number but missing cc-name");
+ fillField(ccNumber, "4111111111111111");
+ ok(ccNumber.checkValidity(), "cc-number field is valid with good input");
+ ok(!nameInput.checkValidity(), "cc-name field is invalid when empty");
+ ok(form.saveButton.disabled, "Save button should be disabled with incomplete input");
+
+ info("correct by adding cc-name and expiration values");
+ fillField(nameInput, "First");
+ fillField(monthInput, "11");
+ let year = (new Date()).getFullYear().toString();
+ fillField(yearInput, year);
+ fillField(typeInput, "visa");
+ fillField(cscInput, "456");
+ ok(ccNumber.checkValidity(), "cc-number field is valid with good input");
+ ok(nameInput.checkValidity(), "cc-name field is valid with a value");
+ ok(monthInput.checkValidity(), "cc-exp-month field is valid with a value");
+ ok(yearInput.checkValidity(), "cc-exp-year field is valid with a value");
+ ok(typeInput.checkValidity(), "cc-type field is valid with a value");
+
+ // should auto-select the first billing address
+ ok(addressPicker.value, "An address is selected: " + addressPicker.value);
+
+ let fieldsVisiblyInvalid = form.querySelectorAll(":-moz-ui-invalid");
+ for (let field of fieldsVisiblyInvalid) {
+ info("invalid field: " + field.localName + "#" + field.id + "." + field.className);
+ }
+ is(fieldsVisiblyInvalid.length, 0, "No fields are visibly invalid");
+
+ ok(!form.saveButton.disabled, "Save button should not be disabled with good input");
+
+ info("edit to make the cc-number invalid");
+ ccNumber.focus();
+ sendString("aa");
+ nameInput.focus();
+ sendString("Surname");
+
+ ok(!ccNumber.checkValidity(), "cc-number field becomes invalid with bad input");
+ ok(form.querySelector("#cc-number:-moz-ui-invalid"), "cc-number field is visibly invalid");
+ ok(nameInput.checkValidity(), "cc-name field is valid with a value");
+ ok(form.saveButton.disabled, "Save button becomes disabled with bad input");
+
+ info("fix the cc-number to make it all valid again");
+ ccNumber.focus();
+ sendKey("BACK_SPACE");
+ sendKey("BACK_SPACE");
+ info("after backspaces, ccNumber.value: " + ccNumber.value);
+
+ ok(ccNumber.checkValidity(), "cc-number field becomes valid with corrected input");
+ ok(nameInput.checkValidity(), "cc-name field is valid with a value");
+ ok(!form.saveButton.disabled, "Save button is no longer disabled with corrected input");
+
+ form.remove();
+});
+
+add_task(async function test_numberCustomValidityReset() {
+ let form = new BasicCardForm();
+ form.dataset.updateButtonLabel = "Update";
+ await form.promiseReady;
+ display.appendChild(form);
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, {guid: "TimBLGUID"});
+ await form.requestStore.setState({
+ request: {
+ paymentMethods,
+ paymentDetails: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ });
+ await asyncElementRendered();
+
+ fillField(form.querySelector("#cc-number"), "junk");
+ sendKey("TAB");
+ ok(form.querySelector("#cc-number:-moz-ui-invalid"), "cc-number field is visibly invalid");
+
+ info("simulate triggering an add again to reset the form");
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ selectedStateKey: "selectedPaymentCard",
+ },
+ });
+
+ ok(!form.querySelector("#cc-number:-moz-ui-invalid"), "cc-number field is not visibly invalid");
+
+ form.remove();
+});
+
+add_task(async function test_noCardNetworkSelected() {
+ let form = new BasicCardForm();
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ info("have an existing card in storage, with no network id");
+ let card1 = deepClone(PTU.BasicCards.JohnDoe);
+ card1.guid = "9864798564";
+ delete card1["cc-type"];
+
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ guid: card1.guid,
+ selectedStateKey: "selectedPaymentCard",
+ },
+ savedBasicCards: {
+ [card1.guid]: deepClone(card1),
+ },
+ });
+ await asyncElementRendered();
+ checkCCForm(form, card1);
+ is(document.getElementById("cc-type").selectedIndex, 0, "Initial empty option is selected");
+
+ form.remove();
+ await form.requestStore.reset();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_basic_card_option.html b/browser/components/payments/test/mochitest/test_basic_card_option.html
new file mode 100644
index 0000000000..71a0199fec
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_basic_card_option.html
@@ -0,0 +1,96 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the basic-card-option component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the basic-card-option component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.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">
+ <option id="option1"
+ value="option1"
+ cc-exp="2024-06"
+ cc-name="John Smith"
+ cc-number="************5461"
+ cc-type="visa"
+ guid="option1"></option>
+ <option id="option2"
+ value="option2"
+ cc-number="************1111"
+ guid="option2"></option>
+
+ <rich-select id="richSelect1"
+ option-type="basic-card-option"></rich-select>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the basic-card-option component **/
+
+import "../../res/components/basic-card-option.js";
+import "../../res/components/rich-select.js";
+
+let option1 = document.getElementById("option1");
+let option2 = document.getElementById("option2");
+let richSelect1 = document.getElementById("richSelect1");
+
+add_task(async function test_populated_option_rendering() {
+ richSelect1.popupBox.appendChild(option1);
+ richSelect1.value = option1.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ is(richOption.ccExp, "2024-06", "Check ccExp getter");
+ is(richOption.ccName, "John Smith", "Check ccName getter");
+ is(richOption.ccNumber, "************5461", "Check ccNumber getter");
+ is(richOption.ccType, "visa", "Check ccType getter");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ // Note that innerText takes visibility into account so that's why it's used over textContent here
+ is(richOption["_cc-exp"].innerText, "Exp. 2024-06", "cc-exp text");
+ is(richOption["_cc-name"].innerText, "John Smith", "cc-name text");
+ is(richOption["_cc-number"].innerText, "****5461", "cc-number text");
+ is(richOption["_cc-type"].localName, "img", "cc-type localName");
+ is(richOption["_cc-type"].alt, "visa", "cc-type img alt");
+});
+
+add_task(async function test_minimal_option_rendering() {
+ richSelect1.popupBox.appendChild(option2);
+ richSelect1.value = option2.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ is(richOption.ccExp, null, "Check ccExp getter");
+ is(richOption.ccName, null, "Check ccName getter");
+ is(richOption.ccNumber, "************1111", "Check ccNumber getter");
+ is(richOption.ccType, null, "Check ccType getter");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ is(richOption["_cc-exp"].innerText, "", "cc-exp text");
+ is(richOption["_cc-name"].innerText, "", "cc-name text");
+ is(richOption["_cc-number"].innerText, "****1111", "cc-number text");
+ is(richOption["_cc-type"].localName, "img", "cc-type localName");
+ is(richOption["_cc-type"].alt, "", "cc-type img alt");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_billing_address_picker.html b/browser/components/payments/test/mochitest/test_billing_address_picker.html
new file mode 100644
index 0000000000..0039c97e55
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_billing_address_picker.html
@@ -0,0 +1,132 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the address-picker component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the billing-address-picker component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <billing-address-picker id="picker1"
+ data-field-separator=", "
+ data-invalid-label="Picker1: Missing or Invalid"
+ selected-state-key="basic-card-page|billingAddressGUID"></billing-address-picker>
+ <select id="theOptions">
+ <option></option>
+ <option value="48bnds6854t">48bnds6854t</option>
+ <option value="68gjdh354j" selected="">68gjdh354j</option>
+ </select>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the billing-address-picker component **/
+
+import BillingAddressPicker from "../../res/containers/billing-address-picker.js";
+
+let picker1 = document.getElementById("picker1");
+let addresses = {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ "country": "US",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ "tel": "+1 519 555-5555",
+ timeLastUsed: 200,
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ timeLastUsed: 300,
+ },
+ "abcde12345": {
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "abcde12345",
+ "name": "Mrs. Fields",
+ timeLastUsed: 100,
+ },
+};
+
+add_task(async function test_empty() {
+ ok(picker1, "Check picker1 exists");
+ let {savedAddresses} = picker1.requestStore.getState();
+ is(Object.keys(savedAddresses).length, 0, "Check empty initial state");
+ is(picker1.editLink.hidden, true, "Check that picker edit link is hidden");
+ is(picker1.options.length, 1, "Check only the empty option is present");
+ ok(picker1.dropdown.selectedOption, "Has a selectedOption");
+ is(picker1.dropdown.value, "", "Has empty value");
+
+ // update state to trigger render without changing available addresses
+ picker1.requestStore.setState({
+ "basic-card-page": {
+ "someKey": "someValue",
+ },
+ });
+ await asyncElementRendered();
+
+ is(picker1.dropdown.popupBox.children.length, 1, "Check only the empty option is present");
+ ok(picker1.dropdown.selectedOption, "Has a selectedOption");
+ is(picker1.dropdown.value, "", "Has empty value");
+});
+
+add_task(async function test_getCurrentValue() {
+ picker1.requestStore.setState({
+ "basic-card-page": {
+ "billingAddressGUID": "68gjdh354j",
+ },
+ savedAddresses: addresses,
+ });
+ await asyncElementRendered();
+
+ picker1.dropdown.popupBox.value = "abcde12345";
+
+ is(picker1.options.length, 4, "Check we have options for each address + empty one");
+ is(picker1.getCurrentValue(picker1.requestStore.getState()), "abcde12345",
+ "Initial/current value reflects the <select>.value, " +
+ "not whatever is in the state at the selectedStateKey");
+});
+
+add_task(async function test_wrapPopupBox() {
+ let picker = new BillingAddressPicker();
+ picker.dropdown.popupBox = document.querySelector("#theOptions");
+ picker.dataset.invalidLabel = "Invalid";
+ picker.setAttribute("label", "The label");
+ picker.setAttribute("selected-state-key", "basic-card-page|billingAddressGUID");
+
+ document.querySelector("#display").appendChild(picker);
+
+ is(picker.labelElement.getAttribute("for"), "theOptions",
+ "The label points at the right element");
+ is(picker.invalidLabel.getAttribute("for"), "theOptions",
+ "The invalidLabel points at the right element");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_completion_error_page.html b/browser/components/payments/test/mochitest/test_completion_error_page.html
new file mode 100644
index 0000000000..cb8e809758
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_completion_error_page.html
@@ -0,0 +1,88 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the completion-error-page component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the completion-error-page component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <completion-error-page id="completion-timeout-error" class="illustrated"
+ data-page-title="Sample Title"
+ data-suggestion-heading="Sample suggestion heading"
+ data-suggestion-1="Sample suggestion"
+ data-suggestion-2="Sample suggestion"
+ data-suggestion-3="Sample suggestion"
+ data-branding-label="Sample Brand"
+ data-done-button-label="OK"></completion-error-page>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the completion-error-page component **/
+
+import "../../res/containers/completion-error-page.js";
+
+let page = document.getElementById("completion-timeout-error");
+
+add_task(async function test_no_values() {
+ ok(page, "page exists");
+ is(page.dataset.pageTitle, "Sample Title", "Title set on page");
+ is(page.dataset.suggestionHeading, "Sample suggestion heading",
+ "Suggestion heading set on page");
+ is(page.dataset["suggestion-1"], "Sample suggestion",
+ "Suggestion 1 set on page");
+ is(page.dataset["suggestion-2"], "Sample suggestion",
+ "Suggestion 2 set on page");
+ is(page.dataset["suggestion-3"], "Sample suggestion",
+ "Suggestion 3 set on page");
+ is(page.dataset.brandingLabel, "Sample Brand", "Branding string set");
+
+ page.dataset.pageTitle = "Oh noes! **host-name** is having an issue";
+ page.dataset["suggestion-2"] = "You should probably blame **host-name**, not us";
+ const displayHost = "allizom.com";
+ let request = { topLevelPrincipal: { URI: { displayHost } } };
+ await page.requestStore.setState({
+ changesPrevented: false,
+ request: Object.assign({}, request, {completeStatus: ""}),
+ orderDetailsShowing: false,
+ page: {
+ id: "completion-timeout-error",
+ },
+ });
+ await asyncElementRendered();
+
+ is(page.requestStore.getState().request.topLevelPrincipal.URI.displayHost, displayHost,
+ "State should have the displayHost set properly");
+ is(page.querySelector("h2").textContent,
+ `Oh noes! ${displayHost} is having an issue`,
+ "Title includes host-name");
+ is(page.querySelector("p").textContent,
+ "Sample suggestion heading",
+ "Suggestion heading set on page");
+ is(page.querySelector("li:nth-child(1)").textContent, "Sample suggestion",
+ "Suggestion 1 set on page");
+ is(page.querySelector("li:nth-child(2)").textContent,
+ `You should probably blame ${displayHost}, not us`,
+ "Suggestion 2 includes host-name");
+ is(page.querySelector(".branding").textContent,
+ "Sample Brand",
+ "Branding set on page");
+ is(page.querySelector(".primary").textContent,
+ "OK",
+ "Primary button label set correctly");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_currency_amount.html b/browser/components/payments/test/mochitest/test_currency_amount.html
new file mode 100644
index 0000000000..dc1cbac8f2
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_currency_amount.html
@@ -0,0 +1,160 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the currency-amount component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the currency-amount component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <currency-amount id="amount1"></currency-amount>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the currency-amount component **/
+
+import "../../res/components/currency-amount.js";
+
+let amount1 = document.getElementById("amount1");
+
+add_task(async function test_no_value() {
+ ok(amount1, "amount1 exists");
+ is(amount1.textContent, "", "Initially empty");
+
+ amount1.currency = "USD";
+ await asyncElementRendered();
+ is(amount1.getAttribute("currency"), "USD", "Check @currency");
+ ok(!amount1.hasAttribute("value"), "Check @value");
+ is(amount1.currency, "USD", "Check .currency");
+ is(amount1.value, null, "Check .value");
+ is(amount1.textContent, "", "Empty while missing an amount");
+
+ amount1.currency = null;
+ await asyncElementRendered();
+ ok(!amount1.hasAttribute("currency"), "Setting to null should remove @currency");
+ ok(!amount1.hasAttribute("value"), "Check @value");
+ is(amount1.currency, null, "Check .currency");
+ is(amount1.value, null, "Check .value");
+});
+
+add_task(async function test_no_value() {
+ amount1.value = 1.23;
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "1.23", "Check @value");
+ ok(!amount1.hasAttribute("currency"), "Check @currency");
+ is(amount1.currency, null, "Check .currency");
+ is(amount1.value, "1.23", "Check .value");
+ is(amount1.textContent, "", "Empty while missing a currency");
+
+ amount1.value = null;
+ await asyncElementRendered();
+ ok(!amount1.hasAttribute("value"), "Setting to null should remove @value");
+ is(amount1.currency, null, "Check .currency");
+ is(amount1.value, null, "Check .value");
+});
+
+add_task(async function test_valid_currency_amount_cad() {
+ amount1.value = 12.34;
+ info("waiting to set second property");
+ await asyncElementRendered();
+ amount1.currency = "CAD";
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "12.34", "Check @value");
+ is(amount1.value, "12.34", "Check .value");
+ is(amount1.getAttribute("currency"), "CAD", "Check @currency");
+ is(amount1.currency, "CAD", "Check .currency");
+ is(amount1.textContent, "CA$12.34", "Check output format");
+});
+
+add_task(async function test_valid_currency_amount_displayCode() {
+ amount1.value = 12.34;
+ info("showing the currency code");
+ await asyncElementRendered();
+ amount1.currency = "CAD";
+ await asyncElementRendered();
+ amount1.displayCode = true;
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "12.34", "Check @value");
+ is(amount1.value, "12.34", "Check .value");
+ is(amount1.getAttribute("currency"), "CAD", "Check @currency");
+ is(amount1.currency, "CAD", "Check .currency");
+ is(amount1.textContent, "CA$12.34 CAD", "Check output format");
+
+ amount1.displayCode = false;
+ await asyncElementRendered();
+});
+
+
+add_task(async function test_valid_currency_amount_eur_batched_prop() {
+ info("setting two properties in a row synchronously");
+ amount1.value = 98.76;
+ amount1.currency = "EUR";
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "98.76", "Check @value");
+ is(amount1.value, "98.76", "Check .value");
+ is(amount1.getAttribute("currency"), "EUR", "Check @currency");
+ is(amount1.currency, "EUR", "Check .currency");
+ is(amount1.textContent, "€98.76", "Check output format");
+});
+
+add_task(async function test_valid_currency_amount_eur_batched_attr() {
+ info("setting two attributes in a row synchronously");
+ amount1.setAttribute("value", 11.88);
+ amount1.setAttribute("currency", "CAD");
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "11.88", "Check @value");
+ is(amount1.value, "11.88", "Check .value");
+ is(amount1.getAttribute("currency"), "CAD", "Check @currency");
+ is(amount1.currency, "CAD", "Check .currency");
+ is(amount1.textContent, "CA$11.88", "Check output format");
+});
+
+add_task(async function test_invalid_currency() {
+ isnot(amount1.textContent, "", "Start with initial content");
+ amount1.value = 33.33;
+ amount1.currency = "__invalid__";
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "33.33", "Check @value");
+ is(amount1.value, "33.33", "Check .value");
+ is(amount1.getAttribute("currency"), "__invalid__", "Check @currency");
+ is(amount1.currency, "__invalid__", "Check .currency");
+ is(amount1.textContent, "", "Invalid currency should clear output");
+});
+
+add_task(async function test_invalid_value() {
+ info("setting some initial values");
+ amount1.value = 4.56;
+ amount1.currency = "GBP";
+ await asyncElementRendered();
+ isnot(amount1.textContent, "", "Start with initial content");
+
+ info("setting an alphabetical invalid value");
+ amount1.value = "abcdef";
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "abcdef", "Check @value");
+ is(amount1.value, "abcdef", "Check .value");
+ is(amount1.getAttribute("currency"), "GBP", "Check @currency");
+ is(amount1.currency, "GBP", "Check .currency");
+ is(amount1.textContent, "", "Invalid value should clear output");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_labelled_checkbox.html b/browser/components/payments/test/mochitest/test_labelled_checkbox.html
new file mode 100644
index 0000000000..2d6b9be98b
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_labelled_checkbox.html
@@ -0,0 +1,71 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the labelled-checkbox component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the labelled-checkbox component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.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 "../../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>
diff --git a/browser/components/payments/test/mochitest/test_order_details.html b/browser/components/payments/test/mochitest/test_order_details.html
new file mode 100644
index 0000000000..c97311d299
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_order_details.html
@@ -0,0 +1,215 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test the order-details component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the order-details component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/containers/order-details.css"/>
+
+ <template id="order-details-template">
+ <ul class="main-list"></ul>
+ <ul class="footer-items-list"></ul>
+
+ <div class="details-total">
+ <h2 class="label">Total</h2>
+ <currency-amount></currency-amount>
+ </div>
+ </template>
+</head>
+<body>
+ <p id="display">
+ <order-details></order-details>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the order-details component **/
+
+import OrderDetails from "../../res/containers/order-details.js";
+import {requestStore} from "../../res/mixins/PaymentStateSubscriberMixin.js";
+
+let orderDetails = document.querySelector("order-details");
+let emptyState = requestStore.getState();
+
+function setup() {
+ let initialState = deepClone(emptyState);
+ let cardGUID = "john-doe";
+ let johnDoeCard = deepClone(PTU.BasicCards.JohnDoe);
+ johnDoeCard.methodName = "basic-card";
+ johnDoeCard.guid = cardGUID;
+ let savedBasicCards = {
+ [cardGUID]: johnDoeCard,
+ };
+ initialState.selectedPaymentCard = cardGUID;
+ requestStore.setState(Object.assign(initialState, {savedBasicCards}));
+}
+
+add_task(async function isFooterItem() {
+ ok(OrderDetails.isFooterItem({
+ label: "Levy",
+ type: "tax",
+ amount: { currency: "USD", value: "1" },
+ }, "items with type of 'tax' are footer items"));
+ ok(!OrderDetails.isFooterItem({
+ label: "Levis",
+ amount: { currency: "USD", value: "1" },
+ }, "items without type of 'tax' aren't footer items"));
+});
+
+add_task(async function test_initial_state() {
+ setup();
+ is(orderDetails.mainItemsList.childElementCount, 0, "main items list is initially empty");
+ is(orderDetails.footerItemsList.childElementCount, 0, "footer items list is initially empty");
+ is(orderDetails.totalAmountElem.value, "0", "total amount is 0");
+});
+
+add_task(async function test_list_population() {
+ setup();
+ let state = requestStore.getState();
+ let request = state.request;
+ let paymentDetails = deepClone(request.paymentDetails);
+ paymentDetails.displayItems = [
+ {
+ label: "One",
+ amount: { currency: "USD", value: "5" },
+ },
+ {
+ label: "Two",
+ amount: { currency: "USD", value: "6" },
+ },
+ {
+ label: "Three",
+ amount: { currency: "USD", value: "7" },
+ },
+ ];
+
+ requestStore.setState({
+ request: Object.assign(deepClone(request), { paymentDetails }),
+ });
+
+ await asyncElementRendered();
+ is(orderDetails.mainItemsList.childElementCount, 3, "main items list has correct # children");
+ is(orderDetails.footerItemsList.childElementCount, 0, "footer items list has 0 children");
+
+ paymentDetails.displayItems = [
+ {
+ label: "Levy",
+ type: "tax",
+ amount: { currency: "USD", value: "1" },
+ },
+ {
+ label: "Item",
+ amount: { currency: "USD", value: "6" },
+ },
+ {
+ label: "Thing",
+ amount: { currency: "USD", value: "7" },
+ },
+ ];
+ Object.assign(request, { paymentDetails });
+ requestStore.setState({ request });
+ await asyncElementRendered();
+
+ is(orderDetails.mainItemsList.childElementCount, 2, "main list has correct # children");
+ is(orderDetails.footerItemsList.childElementCount, 1, "footer list has correct # children");
+});
+
+add_task(async function test_additionalDisplayItems() {
+ setup();
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails, {
+ modifiers: [{
+ additionalDisplayItems: [
+ {
+ label: "Card fee",
+ amount: { currency: "USD", value: "1.50" },
+ },
+ ],
+ supportedMethods: "basic-card",
+ total: {
+ label: "Total due",
+ amount: { currency: "USD", value: "3.50" },
+ },
+ }],
+ });
+ requestStore.setState({ request });
+ await asyncElementRendered();
+
+ is(orderDetails.mainItemsList.childElementCount, 0,
+ "main list added 0 children from additionalDisplayItems");
+ is(orderDetails.footerItemsList.childElementCount, 1,
+ "footer list added children from additionalDisplayItems");
+});
+
+
+add_task(async function test_total() {
+ setup();
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails, {
+ totalItem: { label: "foo", amount: { currency: "JPY", value: "5" }},
+ });
+ requestStore.setState({ request });
+ await asyncElementRendered();
+
+ is(orderDetails.totalAmountElem.value, "5", "total amount gets updated");
+ is(orderDetails.totalAmountElem.currency, "JPY", "total currency gets updated");
+});
+
+add_task(async function test_modified_total() {
+ setup();
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails, {
+ totalItem: { label: "foo", amount: { currency: "JPY", value: "5" }},
+ modifiers: [{
+ supportedMethods: "basic-card",
+ total: {
+ label: "Total due",
+ amount: { currency: "USD", value: "3.5" },
+ },
+ }],
+ });
+ requestStore.setState({request});
+ await asyncElementRendered();
+
+ is(orderDetails.totalAmountElem.value, "3.5", "total amount uses modifier total");
+ is(orderDetails.totalAmountElem.currency, "USD", "total currency uses modifier currency");
+});
+
+// The modifier is not applied since the cc network is not supported.
+add_task(async function test_non_supported_network() {
+ setup();
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails, {
+ totalItem: { label: "foo", amount: { currency: "JPY", value: "5" }},
+ modifiers: [{
+ supportedMethods: "basic-card",
+ total: {
+ label: "Total due",
+ amount: { currency: "USD", value: "3.5" },
+ },
+ data: {
+ supportedNetworks: ["mastercard"],
+ },
+ }],
+ });
+ requestStore.setState({request});
+ await asyncElementRendered();
+
+ is(orderDetails.totalAmountElem.value, "5", "total amount uses modifier total");
+ is(orderDetails.totalAmountElem.currency, "JPY", "total currency uses modifier currency");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_payer_address_picker.html b/browser/components/payments/test/mochitest/test_payer_address_picker.html
new file mode 100644
index 0000000000..df14857e69
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_payer_address_picker.html
@@ -0,0 +1,323 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the paymentOptions address-picker
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the paymentOptions address-picker</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display" style="height: 100vh; margin: 0;">
+ <iframe id="templateFrame" src="../../res/paymentRequest.xhtml" width="0" height="0"
+ sandbox="allow-same-origin"
+ style="float: left;"></iframe>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the payer requested details functionality **/
+
+import PaymentDialog from "../../res/containers/payment-dialog.js";
+
+function isVisible(elem) {
+ let result = elem.getBoundingClientRect().height > 0;
+ return result;
+}
+
+function setPaymentOptions(requestStore, options) {
+ let {request} = requestStore.getState();
+ request = Object.assign({}, request, {
+ paymentOptions: options,
+ });
+ return requestStore.setState({ request });
+}
+
+const SAVED_ADDRESSES = {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ "country": "US",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ "tel": "+1 519 555-5555",
+ "email": "foo@example.com",
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ "email": "bar@example.com",
+ },
+};
+
+let DUPED_ADDRESSES = {
+ "a9e830667189": {
+ "street-address": "Unit 1\n1505 Northeast Kentucky Industrial Parkway \n",
+ "address-level2": "Greenup",
+ "address-level1": "KY",
+ "postal-code": "41144",
+ "country": "US",
+ "email": "bob@example.com",
+ "guid": "a9e830667189",
+ "name": "Bob Smith",
+ },
+ "72a15aed206d": {
+ "street-address": "1 New St",
+ "address-level2": "York",
+ "address-level1": "SC",
+ "postal-code": "29745",
+ "country": "US",
+ "guid": "72a15aed206d",
+ "email": "mary@example.com",
+ "name": "Mary Sue",
+ },
+ "2b4dce0fbc1f": {
+ "street-address": "123 Park St",
+ "address-level2": "Springfield",
+ "address-level1": "OR",
+ "postal-code": "97403",
+ "country": "US",
+ "email": "rita@foo.com",
+ "guid": "2b4dce0fbc1f",
+ "name": "Rita Foo",
+ },
+ "46b2635a5b26": {
+ "street-address": "432 Another St",
+ "address-level2": "Springfield",
+ "address-level1": "OR",
+ "postal-code": "97402",
+ "country": "US",
+ "guid": "46b2635a5b26",
+ "name": "Rita Foo",
+ "tel": "+19871234567",
+ },
+};
+
+let elPicker;
+let elDialog;
+let initialState;
+
+add_task(async function setup_once() {
+ registerConsoleFilter(function consoleFilter(msg) {
+ return msg.errorMessage &&
+ msg.errorMessage.toString().includes("selectedPayerAddress option a9e830667189 " +
+ "does not exist");
+ });
+
+ let templateFrame = document.getElementById("templateFrame");
+ await SimpleTest.promiseFocus(templateFrame.contentWindow);
+
+ let displayEl = document.getElementById("display");
+ importDialogDependencies(templateFrame, displayEl);
+
+ elDialog = new PaymentDialog();
+ displayEl.appendChild(elDialog);
+ elPicker = elDialog.querySelector("address-picker.payer-related");
+
+ let {request} = elDialog.requestStore.getState();
+ initialState = Object.assign({}, {
+ changesPrevented: false,
+ request: Object.assign({}, request, { completeStatus: "" }),
+ orderDetailsShowing: false,
+ });
+});
+
+async function setup() {
+ // reset the store back to a known, default state
+ elDialog.requestStore.setState(deepClone(initialState));
+ await asyncElementRendered();
+}
+
+add_task(async function test_empty() {
+ await setup();
+
+ let {request, savedAddresses} = elPicker.requestStore.getState();
+ ok(!savedAddresses || !savedAddresses.length,
+ "Check initial state has no saved addresses");
+
+ let {paymentOptions} = request;
+ let payerRequested = paymentOptions.requestPayerName ||
+ paymentOptions.requestPayerEmail ||
+ paymentOptions.requestPayerPhone;
+ ok(!payerRequested, "Check initial state has no payer details requested");
+ ok(elPicker, "Check elPicker exists");
+ is(elPicker.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
+ is(isVisible(elPicker), false, "The address-picker is not visible");
+});
+
+// paymentOptions properties are acurately reflected in the address-fields attribute
+add_task(async function test_visible_fields() {
+ await setup();
+ let requestStore = elPicker.requestStore;
+ setPaymentOptions(requestStore, {
+ requestPayerName: true,
+ requestPayerEmail: true,
+ requestPayerPhone: true,
+ });
+
+ requestStore.setState({
+ savedAddresses: SAVED_ADDRESSES,
+ selectedPayerAddress: "48bnds6854t",
+ });
+
+ await asyncElementRendered();
+
+ let closedRichOption = elPicker.dropdown.querySelector(".rich-select-selected-option");
+ is(elPicker.dropdown.popupBox.children.length, 2, "Check dropdown has 2 addresses");
+ is(closedRichOption.getAttribute("guid"), "48bnds6854t", "expected option is visible");
+
+ for (let fieldName of ["name", "email", "tel"]) {
+ let elem = closedRichOption.querySelector(`.${fieldName}`);
+ ok(elem, `field ${fieldName} exists`);
+ ok(isVisible(elem), `field ${fieldName} is visible`);
+ }
+ ok(!closedRichOption.querySelector(".street-address"), "street-address element is not present");
+});
+
+add_task(async function test_selective_fields() {
+ await setup();
+ let requestStore = elPicker.requestStore;
+
+ requestStore.setState({
+ savedAddresses: SAVED_ADDRESSES,
+ selectedPayerAddress: "48bnds6854t",
+ });
+
+ let payerFieldVariations = [
+ {requestPayerName: true, requestPayerEmail: true, requestPayerPhone: true },
+ {requestPayerName: true, requestPayerEmail: false, requestPayerPhone: false },
+ {requestPayerName: false, requestPayerEmail: true, requestPayerPhone: false },
+ {requestPayerName: false, requestPayerEmail: false, requestPayerPhone: true },
+ {requestPayerName: true, requestPayerEmail: true, requestPayerPhone: false },
+ {requestPayerName: false, requestPayerEmail: true, requestPayerPhone: true },
+ {requestPayerName: true, requestPayerEmail: false, requestPayerPhone: true },
+ ];
+
+ for (let payerFields of payerFieldVariations) {
+ setPaymentOptions(requestStore, payerFields);
+ await asyncElementRendered();
+
+ let closedRichOption = elPicker.dropdown.querySelector(".rich-select-selected-option");
+ let elName = closedRichOption.querySelector(".name");
+ let elEmail = closedRichOption.querySelector(".email");
+ let elPhone = closedRichOption.querySelector(".tel");
+
+ is(!!elName && isVisible(elName), payerFields.requestPayerName,
+ "name field is correctly toggled");
+ is(!!elEmail && isVisible(elEmail), payerFields.requestPayerEmail,
+ "email field is correctly toggled");
+ is(!!elPhone && isVisible(elPhone), payerFields.requestPayerPhone,
+ "tel field is correctly toggled");
+
+ let numPayerFieldsRequested = [...Object.values(payerFields)].filter(val => val).length;
+ is(elPicker.getAttribute("break-after-nth-field"), numPayerFieldsRequested == 3 ? "1" : null,
+ "Check @break-after-nth-field");
+ if (numPayerFieldsRequested == 3) {
+ is(closedRichOption.breakAfterNthField, "1",
+ "Make sure @break-after-nth-field was propagated to <address-option>");
+ } else {
+ is(closedRichOption.breakAfterNthField, null, "Make sure @break-after-nth-field was cleared");
+ }
+ }
+});
+
+add_task(async function test_filtered_options() {
+ await setup();
+ let requestStore = elPicker.requestStore;
+ setPaymentOptions(requestStore, {
+ requestPayerName: true,
+ requestPayerEmail: true,
+ });
+
+ requestStore.setState({
+ savedAddresses: DUPED_ADDRESSES,
+ selectedPayerAddress: "a9e830667189",
+ });
+
+ await asyncElementRendered();
+
+ let closedRichOption = elPicker.dropdown.querySelector(".rich-select-selected-option");
+ is(elPicker.dropdown.popupBox.children.length, 4, "Check dropdown has 4 addresses");
+ is(closedRichOption.getAttribute("guid"), "a9e830667189", "expected option is visible");
+
+ for (let fieldName of ["name", "email"]) {
+ let elem = closedRichOption.querySelector(`.${fieldName}`);
+ ok(elem, `field ${fieldName} exists`);
+ ok(isVisible(elem), `field ${fieldName} is visible`);
+ }
+
+ // The selectedPayerAddress (a9e830667189) doesn't have a phone number and
+ // therefore will cause an error.
+ SimpleTest.expectUncaughtException(true);
+
+ setPaymentOptions(requestStore, {
+ requestPayerPhone: true,
+ });
+ await asyncElementRendered();
+
+ is(elPicker.dropdown.popupBox.children.length, 1, "Check dropdown has 1 addresses");
+
+ setPaymentOptions(requestStore, {});
+ await asyncElementRendered();
+
+ is(elPicker.dropdown.popupBox.children.length, 4, "Check dropdown has 4 addresses");
+});
+
+add_task(async function test_no_matches() {
+ await setup();
+ let requestStore = elPicker.requestStore;
+ setPaymentOptions(requestStore, {
+ requestPayerPhone: true,
+ });
+
+ // The selectedPayerAddress (a9e830667189) doesn't have a phone number and
+ // therefore will cause an error.
+ SimpleTest.expectUncaughtException(true);
+
+ requestStore.setState({
+ savedAddresses: {
+ "2b4dce0fbc1f": {
+ "email": "rita@foo.com",
+ "guid": "2b4dce0fbc1f",
+ "name": "Rita Foo",
+ },
+ "46b2635a5b26": {
+ "guid": "46b2635a5b26",
+ "name": "Rita Foo",
+ },
+ },
+ selectedPayerAddress: "a9e830667189",
+ });
+
+ await asyncElementRendered();
+
+ let closedRichOption = elPicker.dropdown.querySelector(".rich-select-selected-option");
+ is(elPicker.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
+ ok(closedRichOption.localName !== "address-option", "No option is selected and visible");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_payment_details_item.html b/browser/components/payments/test/mochitest/test_payment_details_item.html
new file mode 100644
index 0000000000..1a2cf302a4
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_payment_details_item.html
@@ -0,0 +1,65 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the payment-details-item component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the payment-details-item component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <payment-details-item id="item1"></payment-details-item>
+ <payment-details-item id="item2" label="Some item" amount-value="2" amount-currency="USD"></payment-details-item>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the payment-details-item component **/
+
+import "../../res/components/payment-details-item.js";
+
+let item1 = document.getElementById("item1");
+let item2 = document.getElementById("item2");
+
+add_task(async function test_no_value() {
+ ok(item1, "item1 exists");
+ is(item1.textContent, "", "Initially empty");
+
+ item1.label = "New label";
+ await asyncElementRendered();
+ is(item1.getAttribute("label"), "New label", "Check @label");
+ ok(!item1.hasAttribute("amount-value"), "Check @amount-value");
+ ok(!item1.hasAttribute("amount-currency"), "Check @amount-currency");
+ is(item1.label, "New label", "Check .label");
+ is(item1.amountValue, null, "Check .amountValue");
+ is(item1.amountCurrency, null, "Check .amountCurrency");
+
+ item1.label = null;
+ await asyncElementRendered();
+ ok(!item1.hasAttribute("label"), "Setting to null should remove @label");
+ is(item1.textContent, "", "Becomes empty when label is removed");
+});
+
+add_task(async function test_initial_attribute_values() {
+ is(item2.label, "Some item", "Check .label");
+ is(item2.amountValue, "2", "Check .amountValue");
+ is(item2.amountCurrency, "USD", "Check .amountCurrency");
+});
+
+add_task(async function test_templating() {
+ ok(item2.querySelector("currency-amount"), "creates currency-amount component");
+ ok(item2.querySelector(".label"), "creates label");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_payment_dialog.html b/browser/components/payments/test/mochitest/test_payment_dialog.html
new file mode 100644
index 0000000000..200c91ec08
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_payment_dialog.html
@@ -0,0 +1,360 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the payment-dialog custom element
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the payment-dialog element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
+</head>
+<body>
+ <p id="display" style="height: 100vh; margin: 0;">
+ <iframe id="templateFrame" src="paymentRequest.xhtml" width="0" height="0"
+ sandbox="allow-same-origin"
+ style="float: left;"></iframe>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the payment-dialog element **/
+
+/* global sinon */
+
+import PaymentDialog from "../../res/containers/payment-dialog.js";
+
+let el1;
+
+add_task(async function setup_once() {
+ let templateFrame = document.getElementById("templateFrame");
+ await SimpleTest.promiseFocus(templateFrame.contentWindow);
+ let displayEl = document.getElementById("display");
+ importDialogDependencies(templateFrame, displayEl);
+
+ el1 = new PaymentDialog();
+ displayEl.appendChild(el1);
+
+ sinon.spy(el1, "render");
+ sinon.spy(el1, "stateChangeCallback");
+});
+
+async function setup() {
+ let {request} = el1.requestStore.getState();
+ await el1.requestStore.setState({
+ changesPrevented: false,
+ request: Object.assign({}, request, {completeStatus: ""}),
+ orderDetailsShowing: false,
+ page: {
+ id: "payment-summary",
+ },
+ });
+
+ el1.render.reset();
+ el1.stateChangeCallback.reset();
+}
+
+add_task(async function test_initialState() {
+ await setup();
+ let initialState = el1.requestStore.getState();
+ let elDetails = el1._orderDetailsOverlay;
+
+ is(initialState.orderDetailsShowing, false, "orderDetailsShowing is initially false");
+ ok(elDetails.hasAttribute("hidden"), "Check details are hidden");
+ is(initialState.page.id, "payment-summary", "Check initial page");
+});
+
+add_task(async function test_viewAllButtonVisibility() {
+ await setup();
+
+ let button = el1._viewAllButton;
+ ok(button.hidden, "Button is initially hidden when there are no items to show");
+ ok(isHidden(button), "Button should be visibly hidden since bug 1469464");
+
+ // Add a display item.
+ let request = deepClone(el1.requestStore.getState().request);
+ request.paymentDetails.displayItems = [
+ {
+ "label": "Triangle",
+ "amount": {
+ "currency": "CAD",
+ "value": "3",
+ },
+ },
+ ];
+ await el1.requestStore.setState({ request });
+ await asyncElementRendered();
+
+ // Check if the "View all items" button is visible.
+ ok(!button.hidden, "Button is visible");
+});
+
+add_task(async function test_viewAllButton() {
+ await setup();
+
+ let elDetails = el1._orderDetailsOverlay;
+ let button = el1._viewAllButton;
+
+ button.click();
+ await asyncElementRendered();
+
+ ok(el1.stateChangeCallback.calledOnce, "stateChangeCallback called once");
+ ok(el1.render.calledOnce, "render called once");
+
+ let state = el1.requestStore.getState();
+ is(state.orderDetailsShowing, true, "orderDetailsShowing becomes true");
+ ok(!elDetails.hasAttribute("hidden"), "Check details aren't hidden");
+});
+
+add_task(async function test_changesPrevented() {
+ await setup();
+ let state = el1.requestStore.getState();
+ is(state.changesPrevented, false, "changesPrevented is initially false");
+ let disabledOverlay = document.getElementById("disabled-overlay");
+ ok(disabledOverlay.hidden, "Overlay should initially be hidden");
+ await el1.requestStore.setState({changesPrevented: true});
+ await asyncElementRendered();
+ ok(!disabledOverlay.hidden, "Overlay should prevent changes");
+});
+
+add_task(async function test_initial_completeStatus() {
+ await setup();
+ let {request, page} = el1.requestStore.getState();
+ is(request.completeStatus, "", "completeStatus is initially empty");
+
+ let payButton = document.getElementById("pay");
+ is(payButton, document.querySelector(`#${page.id} button.primary`),
+ "Primary button is the pay button in the initial state");
+ is(payButton.textContent, "Pay", "Check default label");
+ ok(payButton.disabled, "Button is disabled by default");
+});
+
+add_task(async function test_generic_errors() {
+ await setup();
+ const SHIPPING_GENERIC_ERROR = "Can't ship to that address";
+ el1._errorText.dataset.shippingGenericError = SHIPPING_GENERIC_ERROR;
+ el1.requestStore.setState({
+ savedAddresses: {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ "country": "US",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ "tel": "+1 519 555-5555",
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ },
+ },
+ selectedShippingAddress: "48bnds6854t",
+ });
+ await asyncElementRendered();
+
+ let picker = el1._shippingAddressPicker;
+ ok(picker.selectedOption, "Address picker should have a selected option");
+ is(el1._errorText.textContent, SHIPPING_GENERIC_ERROR,
+ "Generic error message should be shown when no shipping options or error are provided");
+});
+
+add_task(async function test_processing_completeStatus() {
+ // "processing": has overlay. Check button visibility
+ await setup();
+ let {request} = el1.requestStore.getState();
+ // this a transition state, set when waiting for a response from the merchant page
+ el1.requestStore.setState({
+ changesPrevented: true,
+ request: Object.assign({}, request, {completeStatus: "processing"}),
+ });
+ await asyncElementRendered();
+
+ let primaryButtons = document.querySelectorAll("footer button.primary");
+ ok(Array.from(primaryButtons).every(el => isHidden(el) || el.disabled),
+ "all primary footer buttons are hidden or disabled");
+
+ info("Got an update from the parent process with an error from .retry()");
+ request = el1.requestStore.getState().request;
+ let paymentDetails = deepClone(request.paymentDetails);
+ paymentDetails.error = "Sample retry error";
+ await el1.setStateFromParent({
+ request: Object.assign({}, request, {
+ completeStatus: "",
+ paymentDetails,
+ }),
+ });
+ await asyncElementRendered();
+
+ let {changesPrevented, page} = el1.requestStore.getState();
+ ok(!changesPrevented, "Changes should no longer be prevented");
+ is(page.id, "payment-summary", "Check back on payment-summary");
+ ok(el1.innerText.includes("Sample retry error"), "Check error text is visible");
+});
+
+add_task(async function test_success_unknown_completeStatus() {
+ // in the "success" and "unknown" completion states the dialog would normally be closed
+ // so just ensure it is left in a good state
+ for (let completeStatus of ["success", "unknown"]) {
+ await setup();
+ let {request} = el1.requestStore.getState();
+ el1.requestStore.setState({
+ request: Object.assign({}, request, {completeStatus}),
+ });
+ await asyncElementRendered();
+
+ let {page} = el1.requestStore.getState();
+
+ // this status doesnt change page
+ let payButton = document.getElementById("pay");
+ is(payButton, document.querySelector(`#${page.id} button.primary`),
+ `Primary button is the pay button in the ${completeStatus} state`);
+
+ if (completeStatus == "success") {
+ is(payButton.textContent, "Done", "Check button label");
+ }
+ if (completeStatus == "unknown") {
+ is(payButton.textContent, "Unknown", "Check button label");
+ }
+ ok(payButton.disabled, "Button is disabled by default");
+ }
+});
+
+add_task(async function test_timeout_fail_completeStatus() {
+ // in these states the dialog stays open and presents a single
+ // button for acknowledgement
+ for (let completeStatus of ["fail", "timeout"]) {
+ await setup();
+ let {request} = el1.requestStore.getState();
+ el1.requestStore.setState({
+ request: Object.assign({}, request, {completeStatus}),
+ page: {
+ id: `completion-${completeStatus}-error`,
+ },
+ });
+ await asyncElementRendered();
+
+ let {page} = el1.requestStore.getState();
+ let pageElem = document.querySelector(`#${page.id}`);
+ let payButton = document.getElementById("pay");
+ let primaryButton = pageElem.querySelector("button.primary");
+
+ ok(pageElem && !isHidden(pageElem, `page element for ${page.id} exists and is visible`));
+ ok(!isHidden(primaryButton), "Primary button is visible");
+ ok(payButton != primaryButton,
+ `Primary button is the not pay button in the ${completeStatus} state`);
+ ok(isHidden(payButton), "Pay button is not visible");
+ is(primaryButton.textContent, "Close", "Check button label");
+
+ let rect = primaryButton.getBoundingClientRect();
+ let visibleElement =
+ document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
+ ok(primaryButton === visibleElement, "Primary button is on top of the overlay");
+ }
+});
+
+add_task(async function test_scrollPaymentRequestPage() {
+ await setup();
+ info("making the payment-dialog container small to require scrolling");
+ el1.parentElement.style.height = "100px";
+ let summaryPageBody = document.querySelector("#payment-summary .page-body");
+ is(summaryPageBody.scrollTop, 0, "Page body not scrolled initially");
+ let securityCodeInput = summaryPageBody.querySelector("payment-method-picker input");
+ securityCodeInput.focus();
+ await new Promise(resolve => SimpleTest.executeSoon(resolve));
+ ok(summaryPageBody.scrollTop > 0, "Page body scrolled after focusing the CVV field");
+ el1.parentElement.style.height = "";
+});
+
+add_task(async function test_acceptedCards() {
+ let initialState = el1.requestStore.getState();
+ let paymentMethods = [{
+ supportedMethods: "basic-card",
+ data: {
+ supportedNetworks: ["visa", "mastercard"],
+ },
+ }];
+ el1.requestStore.setState({
+ request: Object.assign({}, initialState.request, {
+ paymentMethods,
+ }),
+ });
+ await asyncElementRendered();
+
+ let acceptedCards = el1._acceptedCardsList;
+ ok(acceptedCards && !isHidden(acceptedCards), "Accepted cards list is present and visible");
+
+ paymentMethods = [{
+ supportedMethods: "basic-card",
+ }];
+ el1.requestStore.setState({
+ request: Object.assign({}, initialState.request, {
+ paymentMethods,
+ }),
+ });
+ await asyncElementRendered();
+
+ acceptedCards = el1._acceptedCardsList;
+ ok(acceptedCards && isHidden(acceptedCards), "Accepted cards list is present but hidden");
+});
+
+add_task(async function test_picker_labels() {
+ await setup();
+ let picker = el1._shippingOptionPicker;
+
+ const SHIPPING_OPTIONS_LABEL = "Shipping options";
+ const DELIVERY_OPTIONS_LABEL = "Delivery options";
+ const PICKUP_OPTIONS_LABEL = "Pickup options";
+ picker.dataset.shippingOptionsLabel = SHIPPING_OPTIONS_LABEL;
+ picker.dataset.deliveryOptionsLabel = DELIVERY_OPTIONS_LABEL;
+ picker.dataset.pickupOptionsLabel = PICKUP_OPTIONS_LABEL;
+
+ for (let [shippingType, label] of [
+ ["shipping", SHIPPING_OPTIONS_LABEL],
+ ["delivery", DELIVERY_OPTIONS_LABEL],
+ ["pickup", PICKUP_OPTIONS_LABEL],
+ ]) {
+ let request = deepClone(el1.requestStore.getState().request);
+ request.paymentOptions.requestShipping = true;
+ request.paymentOptions.shippingType = shippingType;
+ await el1.requestStore.setState({ request });
+ await asyncElementRendered();
+ is(picker.labelElement.textContent, label,
+ `Label should be appropriate for ${shippingType}`);
+ }
+});
+
+add_task(async function test_disconnect() {
+ await setup();
+
+ el1.remove();
+ await el1.requestStore.setState({orderDetailsShowing: true});
+ await asyncElementRendered();
+ ok(el1.stateChangeCallback.notCalled, "stateChangeCallback not called");
+ ok(el1.render.notCalled, "render not called");
+
+ let elDetails = el1._orderDetailsOverlay;
+ ok(elDetails.hasAttribute("hidden"), "details overlay remains hidden");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html b/browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html
new file mode 100644
index 0000000000..88f7080f08
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html
@@ -0,0 +1,252 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the payment-dialog custom element
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the payment-dialog element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
+</head>
+<body>
+ <p id="display" style="height: 100vh; margin: 0;">
+ <iframe id="templateFrame" src="paymentRequest.xhtml" width="0" height="0"
+ sandbox="allow-same-origin"
+ style="float: left;"></iframe>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the payment-dialog element **/
+
+import PaymentDialog from "../../res/containers/payment-dialog.js";
+
+let el1;
+
+add_task(async function setupOnce() {
+ let templateFrame = document.getElementById("templateFrame");
+ await SimpleTest.promiseFocus(templateFrame.contentWindow);
+
+ let displayEl = document.getElementById("display");
+ importDialogDependencies(templateFrame, displayEl);
+
+ el1 = new PaymentDialog();
+ displayEl.appendChild(el1);
+});
+
+async function setup({shippingRequired, payerRequired}) {
+ let state = deepClone(el1.requestStore.getState());
+ state.request.paymentDetails.shippingOptions = shippingRequired ? [{
+ id: "123",
+ label: "Carrier Pigeon",
+ amount: {
+ currency: "USD",
+ value: 10,
+ },
+ selected: false,
+ }, {
+ id: "456",
+ label: "Lightspeed (default)",
+ amount: {
+ currency: "USD",
+ value: 20,
+ },
+ selected: true,
+ }] : null;
+ state.request.paymentOptions.requestShipping = shippingRequired;
+ state.request.paymentOptions.requestPayerName = payerRequired;
+ state.request.paymentOptions.requestPayerPhone = payerRequired;
+ state.savedAddresses = shippingRequired || payerRequired ? {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ "country": "US",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ "tel": "+1 519 555-5555",
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ },
+ "abcdef1234": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "abcdef1234",
+ "name": "Jane Fields",
+ },
+ } : {};
+ state.savedBasicCards = {
+ "john-doe": Object.assign({
+ "cc-exp": (new Date()).getFullYear() + 9 + "-01",
+ methodName: "basic-card",
+ guid: "aaa1",
+ }, deepClone(PTU.BasicCards.JohnDoe)),
+ "missing-fields": Object.assign({
+ methodName: "basic-card",
+ guid: "aaa2",
+ }, deepClone(PTU.BasicCards.MissingFields)),
+ };
+ state.selectedPayerAddress = null;
+ state.selectedPaymentCard = null;
+ state.selectedShippingAddress = null;
+ state.selectedShippingOption = null;
+ await el1.requestStore.setState(state);
+
+ // Fill the security code input so it doesn't interfere with checking the pay
+ // button state for dropdown changes.
+ el1._paymentMethodPicker.securityCodeInput.querySelector("input").select();
+ sendString("123");
+ await asyncElementRendered();
+}
+
+function selectFirstItemOfPicker(picker) {
+ picker.dropdown.popupBox.focus();
+ let options = picker.dropdown.popupBox.children;
+ if (options[0].selected) {
+ ok(false, `"${options[0].textContent}" was already selected`);
+ return;
+ }
+ info(`Selecting "${options[0].textContent}" from the options`);
+
+ synthesizeKey(options[0].textContent.trim().split(/\s+/)[0], {});
+ ok(picker.dropdown.selectedOption, `Option should be selected for ${picker.localName}`);
+}
+
+function selectLastItemOfPicker(picker) {
+ picker.dropdown.popupBox.focus();
+ let options = picker.dropdown.popupBox.children;
+ let lastOption = options[options.length - 1];
+ if (lastOption.selected) {
+ ok(false, `"${lastOption.textContent}" was already selected`);
+ return;
+ }
+
+ synthesizeKey(lastOption.textContent.trim().split(/\s+/)[0], {});
+ ok(picker.dropdown.selectedOption, `Option should be selected for ${picker.localName}`);
+}
+
+add_task(async function runTests() {
+ let allPickers = {
+ shippingAddress: el1._shippingAddressPicker,
+ shippingOption: el1._shippingOptionPicker,
+ paymentMethod: el1._paymentMethodPicker,
+ payerAddress: el1._payerAddressPicker,
+ };
+ let testCases = [
+ {
+ label: "shippingAndPayerRequired",
+ setup: { shippingRequired: true, payerRequired: true },
+ pickers: Object.values(allPickers),
+ }, {
+ label: "payerRequired",
+ setup: { payerRequired: true },
+ pickers: [allPickers.paymentMethod, allPickers.payerAddress],
+ }, {
+ label: "shippingRequired",
+ setup: { shippingRequired: true },
+ pickers: [
+ allPickers.shippingAddress,
+ allPickers.shippingOption,
+ allPickers.paymentMethod,
+ ],
+ },
+ ];
+
+ for (let testCase of testCases) {
+ info(`Starting testcase ${testCase.label}`);
+ await setup(testCase.setup);
+
+ for (let picker of testCase.pickers) {
+ ok(!picker.dropdown.selectedOption, `No option selected for ${picker.localName}`);
+ }
+ let hiddenPickers = Object.values(allPickers).filter(p => !testCase.pickers.includes(p));
+ for (let hiddenPicker of hiddenPickers) {
+ ok(hiddenPicker.hidden, `${hiddenPicker.localName} should be hidden`);
+ }
+
+ let payButton = document.getElementById("pay");
+ ok(payButton.disabled, "Button is disabled when required options are not selected");
+
+ let stateChangedPromise = promiseStateChange(el1.requestStore);
+ testCase.pickers.forEach(selectFirstItemOfPicker);
+ await stateChangedPromise;
+
+ ok(!payButton.disabled, "Button is enabled when required options are selected");
+
+ // Individually toggle each picker to see how the missing fields affects Pay button.
+ for (let picker of testCase.pickers) {
+ // There is no "invalid" option for shipping options.
+ if (picker == allPickers.shippingOption) {
+ continue;
+ }
+ info(`picker: ${picker.localName} with className: ${picker.className}`);
+
+ // Setup the invalid state
+ stateChangedPromise = promiseStateChange(el1.requestStore);
+ selectLastItemOfPicker(picker);
+ await stateChangedPromise;
+
+ ok(payButton.disabled, "Button is disabled when selected option has missing fields");
+
+ // Now setup the valid state
+ stateChangedPromise = promiseStateChange(el1.requestStore);
+ selectFirstItemOfPicker(picker);
+ await stateChangedPromise;
+
+ ok(!payButton.disabled, "Button is enabled when selected option has all required fields");
+ }
+ }
+});
+
+add_task(async function test_securityCodeRequired() {
+ await setup({
+ payerRequired: false,
+ shippingRequired: false,
+ });
+
+ let picker = el1._paymentMethodPicker;
+ let payButton = document.getElementById("pay");
+
+ let stateChangedPromise = promiseStateChange(el1.requestStore);
+ selectFirstItemOfPicker(picker);
+ await stateChangedPromise;
+
+ picker.securityCodeInput.querySelector("input").select();
+ stateChangedPromise = promiseStateChange(el1.requestStore);
+ synthesizeKey("VK_DELETE");
+ await stateChangedPromise;
+
+ ok(payButton.disabled, "Button is disabled when CVV is empty");
+
+ picker.securityCodeInput.querySelector("input").select();
+ stateChangedPromise = promiseStateChange(el1.requestStore);
+ sendString("123");
+ await stateChangedPromise;
+
+ ok(!payButton.disabled, "Button is enabled when CVV is filled");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_payment_method_picker.html b/browser/components/payments/test/mochitest/test_payment_method_picker.html
new file mode 100644
index 0000000000..1e2adee856
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_payment_method_picker.html
@@ -0,0 +1,279 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the payment-method-picker component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the payment-method-picker component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
+ <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"
+ data-invalid-label="picker1: Missing or invalid"
+ selected-state-key="selectedPaymentCard"></payment-method-picker>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the payment-method-picker component **/
+
+import "../../res/components/basic-card-option.js";
+import "../../res/containers/payment-method-picker.js";
+
+let picker1 = document.getElementById("picker1");
+
+add_task(async function test_empty() {
+ ok(picker1, "Check picker1 exists");
+ let {savedBasicCards} = picker1.requestStore.getState();
+ is(Object.keys(savedBasicCards).length, 0, "Check empty initial state");
+ is(picker1.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
+});
+
+add_task(async function test_initialSet() {
+ await picker1.requestStore.setState({
+ savedBasicCards: {
+ "48bnds6854t": {
+ "cc-exp": "2017-02",
+ "cc-exp-month": 2,
+ "cc-exp-year": 2017,
+ "cc-name": "John Doe",
+ "cc-number": "************9999",
+ "cc-type": "mastercard",
+ "guid": "48bnds6854t",
+ timeLastUsed: 300,
+ },
+ "68gjdh354j": {
+ "cc-exp": "2017-08",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2017,
+ "cc-name": "J Smith",
+ "cc-number": "***********1234",
+ "cc-type": "visa",
+ "guid": "68gjdh354j",
+ timeLastUsed: 100,
+ },
+ "123456789abc": {
+ "cc-name": "Jane Fields",
+ "cc-given-name": "Jane",
+ "cc-additional-name": "",
+ "cc-family-name": "Fields",
+ "cc-number": "************9876",
+ "guid": "123456789abc",
+ timeLastUsed: 200,
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 3, "Check dropdown has all three cards");
+ ok(options[0].textContent.includes("John Doe"), "Check first card based on timeLastUsed");
+ ok(options[1].textContent.includes("Jane Fields"), "Check second card based on timeLastUsed");
+ ok(options[2].textContent.includes("J Smith"), "Check third card based on timeLastUsed");
+});
+
+add_task(async function test_update() {
+ await picker1.requestStore.setState({
+ savedBasicCards: {
+ "48bnds6854t": {
+ // Same GUID, different values to trigger an update
+ "cc-exp": "2017-09",
+ "cc-exp-month": 9,
+ "cc-exp-year": 2017,
+ // cc-name was cleared which means it's not returned
+ "cc-number": "************9876",
+ "cc-type": "amex",
+ "guid": "48bnds6854t",
+ },
+ "68gjdh354j": {
+ "cc-exp": "2017-08",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2017,
+ "cc-name": "J Smith",
+ "cc-number": "***********1234",
+ "cc-type": "visa",
+ "guid": "68gjdh354j",
+ },
+ "123456789abc": {
+ "cc-name": "Jane Fields",
+ "cc-given-name": "Jane",
+ "cc-additional-name": "",
+ "cc-family-name": "Fields",
+ "cc-number": "************9876",
+ "guid": "123456789abc",
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 3, "Check dropdown still has three cards");
+ ok(!options[0].textContent.includes("John Doe"), "Check cleared first cc-name");
+ ok(options[0].textContent.includes("9876"), "Check updated first cc-number");
+ ok(options[0].textContent.includes("09"), "Check updated first exp-month");
+
+ ok(options[1].textContent.includes("J Smith"), "Check second card is the same");
+ ok(options[2].textContent.includes("Jane Fields"), "Check third card is the same");
+});
+
+add_task(async function test_change_selected_card() {
+ let options = picker1.dropdown.popupBox.children;
+ is(picker1.dropdown.selectedOption, null, "Should default to no selected option");
+ let {
+ selectedPaymentCard,
+ selectedPaymentCardSecurityCode,
+ } = picker1.requestStore.getState();
+ is(selectedPaymentCard, null, "store should have no option selected");
+ is(selectedPaymentCardSecurityCode, null, "store should have no security code");
+ ok(!picker1.classList.contains("invalid-selected-option"), "No validation on an empty selection");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+
+ await SimpleTest.promiseFocus();
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey("************9876", {});
+ await asyncElementRendered();
+ ok(true, "Focused the security code field");
+ ok(!picker1.open, "Picker should be closed");
+
+ let selectedOption = picker1.dropdown.selectedOption;
+ is(selectedOption, options[2], "Selected option should now be the third option");
+ selectedPaymentCard = picker1.requestStore.getState().selectedPaymentCard;
+ is(selectedPaymentCard, selectedOption.getAttribute("guid"),
+ "store should have third option selected");
+ selectedPaymentCardSecurityCode = picker1.requestStore.getState().selectedPaymentCardSecurityCode;
+ is(selectedPaymentCardSecurityCode, null, "store should have empty security code");
+ ok(picker1.classList.contains("invalid-selected-option"), "Missing fields for the third option");
+ ok(!isHidden(picker1.invalidLabel), "The invalid label should be visible");
+ is(picker1.invalidLabel.innerText, picker1.dataset.invalidLabel, "Check displayed error text");
+
+ await SimpleTest.promiseFocus();
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey("visa", {});
+ await asyncElementRendered();
+ ok(true, "Focused the security code field");
+ ok(!picker1.open, "Picker should be closed");
+
+ selectedOption = picker1.dropdown.selectedOption;
+ is(selectedOption, options[1], "Selected option should now be the second option");
+ selectedPaymentCard = picker1.requestStore.getState().selectedPaymentCard;
+ is(selectedPaymentCard, selectedOption.getAttribute("guid"),
+ "store should have second option selected");
+ selectedPaymentCardSecurityCode = picker1.requestStore.getState().selectedPaymentCardSecurityCode;
+ is(selectedPaymentCardSecurityCode, null, "store should have empty security code");
+ ok(!picker1.classList.contains("invalid-selected-option"), "The second option has all fields");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+
+ let stateChangePromise = promiseStateChange(picker1.requestStore);
+
+ // Type in the security code field
+ picker1.securityCodeInput.querySelector("input").focus();
+ sendString("836");
+ sendKey("Tab");
+ let state = await stateChangePromise;
+ is(state.selectedPaymentCardSecurityCode, "836", "Check security code in state");
+});
+
+add_task(async function test_delete() {
+ await picker1.requestStore.setState({
+ savedBasicCards: {
+ // 48bnds6854t was deleted
+ "68gjdh354j": {
+ "cc-exp": "2017-08",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2017,
+ "cc-name": "J Smith",
+ "cc-number": "***********1234",
+ "cc-type": "visa",
+ "guid": "68gjdh354j",
+ },
+ "123456789abc": {
+ "cc-name": "Jane Fields",
+ "cc-given-name": "Jane",
+ "cc-additional-name": "",
+ "cc-family-name": "Fields",
+ "cc-number": "************9876",
+ "guid": "123456789abc",
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 2, "Check dropdown has two remaining cards");
+ ok(options[0].textContent.includes("J Smith"), "Check remaining card #1");
+ ok(options[1].textContent.includes("Jane Fields"), "Check remaining card #2");
+});
+
+add_task(async function test_supportedNetworks_tempCards() {
+ await picker1.requestStore.reset();
+
+ let request = Object.assign({}, picker1.requestStore.getState().request);
+ request.paymentMethods = [
+ {
+ supportedMethods: "basic-card",
+ data: {
+ supportedNetworks: [
+ "mastercard",
+ "visa",
+ ],
+ },
+ },
+ ];
+
+ await picker1.requestStore.setState({
+ request,
+ selectedPaymentCard: "68gjdh354j",
+ tempBasicCards: {
+ "68gjdh354j": {
+ "cc-exp": "2017-08",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2017,
+ "cc-name": "J Smith",
+ "cc-number": "***********1234",
+ "cc-type": "discover",
+ "guid": "68gjdh354j",
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 1, "Check dropdown has one card");
+ ok(options[0].textContent.includes("J Smith"), "Check remaining card #1");
+
+ ok(picker1.classList.contains("invalid-selected-option"),
+ "Check discover is recognized as not supported");
+ is(picker1.invalidLabel.innerText, picker1.dataset.invalidLabel, "Check displayed error text");
+
+ info("change the card to be a visa");
+ await picker1.requestStore.setState({
+ tempBasicCards: {
+ "68gjdh354j": {
+ "cc-exp": "2017-08",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2017,
+ "cc-name": "J Smith",
+ "cc-number": "***********1234",
+ "cc-type": "visa",
+ "guid": "68gjdh354j",
+ },
+ },
+ });
+ await asyncElementRendered();
+
+ ok(!picker1.classList.contains("invalid-selected-option"),
+ "Check visa is recognized as supported");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_rich_select.html b/browser/components/payments/test/mochitest/test_rich_select.html
new file mode 100644
index 0000000000..e071ed15e2
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_rich_select.html
@@ -0,0 +1,150 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the rich-select component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the rich-select component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/address-option.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">
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the rich-select address-option component **/
+
+import AddressOption from "../../res/components/address-option.js";
+import RichSelect from "../../res/components/rich-select.js";
+
+let addresses = {
+ "58gjdh354k": {
+ "email": "emzembrano92@email.com",
+ "name": "Emily Zembrano",
+ "street-address": "717 Hyde Street #6",
+ "address-level2": "San Francisco",
+ "address-level1": "CA",
+ "tel": "415 203 0845",
+ "postal-code": "94109",
+ "country": "USA",
+ "guid": "58gjdh354k",
+ },
+ "67gjdh354k": {
+ "email": "jenz9382@email.com",
+ "name": "Jennifer Zembrano",
+ "street-address": "42 Fairydust Lane",
+ "address-level2": "Lala Land",
+ "address-level1": "HI",
+ "tel": "415 439 2827",
+ "postal-code": "98765",
+ "country": "USA",
+ "guid": "67gjdh354k",
+ },
+};
+
+let select1 = new RichSelect();
+for (let address of Object.values(addresses)) {
+ let option = document.createElement("option");
+ option.textContent = address.name + " " + address["street-address"];
+ option.setAttribute("value", address.guid);
+ option.dataset.fieldSeparator = ", ";
+ for (let field of Object.keys(address)) {
+ option.setAttribute(field, address[field]);
+ }
+ select1.popupBox.appendChild(option);
+}
+select1.setAttribute("option-type", "address-option");
+select1.value = "";
+document.getElementById("display").appendChild(select1);
+
+let options = select1.popupBox.children;
+let option1 = options[0];
+let option2 = options[1];
+
+function get_selected_clone() {
+ return select1.querySelector(".rich-select-selected-option");
+}
+
+function is_visible(element, message) {
+ ok(!isHidden(element), message);
+}
+
+add_task(async function test_clickable_area() {
+ ok(select1, "select1 exists");
+ isnot(document.activeElement, select1.popupBox, "<select> shouldn't have focus");
+ synthesizeMouseAtCenter(select1, {});
+ is(document.activeElement, select1.popupBox, "<select> should have focus when clicked");
+ document.activeElement.blur();
+});
+
+add_task(async function test_closed_state_on_selection() {
+ ok(select1, "select1 exists");
+ select1.popupBox.focus();
+ synthesizeKey(option1.textContent, {});
+ await asyncElementRendered();
+ ok(option1.selected, "option 1 is now selected");
+
+ let selectedClone = get_selected_clone();
+ is_visible(selectedClone, "The selected clone should be visible at all times");
+ is(selectedClone.getAttribute("email"), option1.getAttribute("email"),
+ "The selected clone email should be equivalent to the selected option 2");
+ is(selectedClone.getAttribute("name"), option1.getAttribute("name"),
+ "The selected clone name should be equivalent to the selected option 2");
+});
+
+add_task(async function test_multi_select_not_supported_in_dropdown() {
+ ok(option1.selected, "Option 1 should be selected from prior test");
+
+ select1.popupBox.focus();
+ synthesizeKey(option2.textContent, {});
+ await asyncElementRendered();
+
+ ok(!option1.selected, "Option 1 should no longer be selected after selecting option1");
+ ok(option2.selected, "Option 2 should now have selected property set to true");
+});
+
+add_task(async function test_selected_clone_should_equal_selected_option() {
+ ok(option2.selected, "option 2 should be selected");
+
+ let clonedOptions = select1.querySelectorAll(".rich-select-selected-option");
+ is(clonedOptions.length, 1, "there should only be one cloned option");
+
+ let clonedOption = clonedOptions[0];
+ for (let attrName of AddressOption.recordAttributes) {
+ is(clonedOption.attributes[attrName] && clonedOption.attributes[attrName].value,
+ option2.attributes[attrName] && option2.attributes[attrName].value,
+ "attributes should have matching value; name=" + attrName);
+ }
+
+ select1.popupBox.focus();
+ synthesizeKey(option1.textContent, {});
+ await asyncElementRendered();
+
+ clonedOptions = select1.querySelectorAll(".rich-select-selected-option");
+ is(clonedOptions.length, 1, "there should only be one cloned option");
+
+ clonedOption = clonedOptions[0];
+ for (let attrName of AddressOption.recordAttributes) {
+ is(clonedOption.attributes[attrName] && clonedOption.attributes[attrName].value,
+ option1.attributes[attrName] && option1.attributes[attrName].value,
+ "attributes should have matching value; name=" + attrName);
+ }
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_shipping_option_picker.html b/browser/components/payments/test/mochitest/test_shipping_option_picker.html
new file mode 100644
index 0000000000..23a075dd3a
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_shipping_option_picker.html
@@ -0,0 +1,180 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the shipping-option-picker component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the shipping-option-picker component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/shipping-option.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <shipping-option-picker id="picker1"></shipping-option-picker>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the shipping-option-picker component **/
+
+import "../../res/containers/shipping-option-picker.js";
+
+let picker1 = document.getElementById("picker1");
+
+add_task(async function test_empty() {
+ ok(picker1, "Check picker1 exists");
+ let state = picker1.requestStore.getState();
+ let {shippingOptions} = state && state.request && state.request.paymentDetails;
+ is(Object.keys(shippingOptions).length, 0, "Check empty initial state");
+ is(picker1.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
+ ok(picker1.editLink.hidden, "Check that picker edit link is always hidden");
+ ok(picker1.addLink.hidden, "Check that picker add link is always hidden");
+});
+
+add_task(async function test_initialSet() {
+ picker1.requestStore.setState({
+ request: {
+ paymentDetails: {
+ shippingOptions: [
+ {
+ id: "123",
+ label: "Carrier Pigeon",
+ amount: {
+ currency: "USD",
+ value: 10,
+ },
+ selected: false,
+ },
+ {
+ id: "456",
+ label: "Lightspeed (default)",
+ amount: {
+ currency: "USD",
+ value: 20,
+ },
+ selected: true,
+ },
+ ],
+ },
+ },
+ selectedShippingOption: "456",
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 2, "Check dropdown has both options");
+ ok(options[0].textContent.includes("Carrier Pigeon"), "Check first option");
+ is(options[0].getAttribute("amount-currency"), "USD", "Check currency");
+ ok(options[1].textContent.includes("Lightspeed (default)"), "Check second option");
+ is(picker1.dropdown.selectedOption, options[1], "Lightspeed selected by default");
+
+ let selectedClone = picker1.dropdown.querySelector(".rich-select-selected-option");
+ let text = selectedClone.textContent;
+ ok(text.includes("$20.00"),
+ "Shipping option clone should include amount. Value = " + text);
+ ok(text.includes("Lightspeed (default)"),
+ "Shipping option clone should include label. Value = " + text);
+ ok(!isHidden(selectedClone),
+ "Shipping option clone should be visible");
+});
+
+add_task(async function test_update() {
+ picker1.requestStore.setState({
+ request: {
+ paymentDetails: {
+ shippingOptions: [
+ {
+ id: "123",
+ label: "Tortoise",
+ amount: {
+ currency: "CAD",
+ value: 10,
+ },
+ selected: false,
+ },
+ {
+ id: "456",
+ label: "Lightspeed (default)",
+ amount: {
+ currency: "USD",
+ value: 20,
+ },
+ selected: true,
+ },
+ ],
+ },
+ },
+ selectedShippingOption: "456",
+ });
+
+ await promiseStateChange(picker1.requestStore);
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 2, "Check dropdown still has both options");
+ ok(options[0].textContent.includes("Tortoise"), "Check updated first option");
+ is(options[0].getAttribute("amount-currency"), "CAD", "Check currency");
+ ok(options[1].textContent.includes("Lightspeed (default)"), "Check second option is the same");
+ is(picker1.dropdown.selectedOption, options[1], "Lightspeed selected by default");
+});
+
+add_task(async function test_change_selected_option() {
+ let options = picker1.dropdown.popupBox.children;
+ let selectedOption = picker1.dropdown.selectedOption;
+ is(options[1], selectedOption, "Should default to Lightspeed option");
+ is(selectedOption.value, "456", "Selected option should have correct ID");
+ let state = picker1.requestStore.getState();
+ let selectedOptionFromState = state.selectedShippingOption;
+ is(selectedOption.value, selectedOptionFromState,
+ "store's selected option should match selected element");
+
+ let stateChangedPromise = promiseStateChange(picker1.requestStore);
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey(options[0].textContent, {});
+ state = await stateChangedPromise;
+
+ selectedOption = picker1.dropdown.selectedOption;
+ is(selectedOption, options[0], "Selected option should now be the first option");
+ is(selectedOption.value, "123", "Selected option should have correct ID");
+ selectedOptionFromState = state.selectedShippingOption;
+ is(selectedOptionFromState, "123", "store should have first option selected");
+});
+
+add_task(async function test_delete() {
+ let stateChangedPromise = promiseStateChange(picker1.requestStore);
+ picker1.requestStore.setState({
+ request: {
+ paymentDetails: {
+ shippingOptions: [
+ {
+ id: "123",
+ label: "Tortoise",
+ amount: {
+ currency: "CAD",
+ value: 10,
+ },
+ selected: false,
+ },
+ // 456 / Lightspeed was deleted
+ ],
+ },
+ },
+ selectedShippingOption: "123",
+ });
+
+ await stateChangedPromise;
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 1, "Check dropdown has one remaining address");
+ ok(options[0].textContent.includes("Tortoise"), "Check remaining address");
+ is(picker1.dropdown.selectedOption, options[0], "Tortoise selected by default");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/unit/head.js b/browser/components/payments/test/unit/head.js
new file mode 100644
index 0000000000..82e1e170c1
--- /dev/null
+++ b/browser/components/payments/test/unit/head.js
@@ -0,0 +1,2 @@
+var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+var { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
diff --git a/browser/components/payments/test/unit/test_response_creation.js b/browser/components/payments/test/unit/test_response_creation.js
new file mode 100644
index 0000000000..2b4b5dc98b
--- /dev/null
+++ b/browser/components/payments/test/unit/test_response_creation.js
@@ -0,0 +1,149 @@
+"use strict";
+
+/**
+ * Basic checks to ensure that helpers constructing responses map their
+ * destructured arguments properly to the `init` methods. Full testing of the init
+ * methods is left to the DOM code.
+ */
+
+const DIALOG_WRAPPER_URI = "chrome://payments/content/paymentDialogWrapper.js";
+let dialogGlobal = {};
+Services.scriptloader.loadSubScript(DIALOG_WRAPPER_URI, dialogGlobal);
+
+add_task(async function test_createBasicCardResponseData_basic() {
+ let expected = {
+ cardholderName: "John Smith",
+ cardNumber: "1234567890",
+ expiryMonth: "01",
+ expiryYear: "2017",
+ cardSecurityCode: "0123",
+ };
+ let actual = dialogGlobal.paymentDialogWrapper.createBasicCardResponseData(
+ expected
+ );
+ Assert.equal(
+ actual.cardholderName,
+ expected.cardholderName,
+ "Check cardholderName"
+ );
+ Assert.equal(actual.cardNumber, expected.cardNumber, "Check cardNumber");
+ Assert.equal(actual.expiryMonth, expected.expiryMonth, "Check expiryMonth");
+ Assert.equal(actual.expiryYear, expected.expiryYear, "Check expiryYear");
+ Assert.equal(
+ actual.cardSecurityCode,
+ expected.cardSecurityCode,
+ "Check cardSecurityCode"
+ );
+});
+
+add_task(async function test_createBasicCardResponseData_minimal() {
+ let expected = {
+ cardNumber: "1234567890",
+ };
+ let actual = dialogGlobal.paymentDialogWrapper.createBasicCardResponseData(
+ expected
+ );
+ info(actual.cardNumber);
+ Assert.equal(actual.cardNumber, expected.cardNumber, "Check cardNumber");
+});
+
+add_task(async function test_createBasicCardResponseData_withoutNumber() {
+ let data = {
+ cardholderName: "John Smith",
+ expiryMonth: "01",
+ expiryYear: "2017",
+ cardSecurityCode: "0123",
+ };
+ Assert.throws(
+ () => dialogGlobal.paymentDialogWrapper.createBasicCardResponseData(data),
+ /NS_ERROR_FAILURE/,
+ "Check cardNumber is required"
+ );
+});
+
+function checkAddress(actual, expected) {
+ for (let [propName, propVal] of Object.entries(expected)) {
+ if (propName == "addressLines") {
+ // Note the singular vs. plural here.
+ Assert.equal(
+ actual.addressLine.length,
+ propVal.length,
+ "Check number of address lines"
+ );
+ for (let [i, line] of expected.addressLines.entries()) {
+ Assert.equal(
+ actual.addressLine.queryElementAt(i, Ci.nsISupportsString).data,
+ line,
+ `Check ${propName} line ${i}`
+ );
+ }
+ continue;
+ }
+ Assert.equal(actual[propName], propVal, `Check ${propName}`);
+ }
+}
+
+add_task(async function test_createPaymentAddress_minimal() {
+ let data = {
+ country: "CA",
+ };
+ let actual = dialogGlobal.paymentDialogWrapper.createPaymentAddress(data);
+ checkAddress(actual, data);
+});
+
+add_task(async function test_createPaymentAddress_basic() {
+ let data = {
+ country: "CA",
+ addressLines: ["123 Sesame Street", "P.O. Box ABC"],
+ region: "ON",
+ city: "Delhi",
+ dependentLocality: "N/A",
+ postalCode: "94041",
+ sortingCode: "1234",
+ organization: "Mozilla Corporation",
+ recipient: "John Smith",
+ phone: "+15195555555",
+ };
+ let actual = dialogGlobal.paymentDialogWrapper.createPaymentAddress(data);
+ checkAddress(actual, data);
+});
+
+add_task(async function test_createShowResponse_basic() {
+ let requestId = "876hmbvfd45hb";
+ dialogGlobal.paymentDialogWrapper.request = {
+ requestId,
+ };
+
+ let cardData = {
+ cardholderName: "John Smith",
+ cardNumber: "1234567890",
+ expiryMonth: "01",
+ expiryYear: "2099",
+ cardSecurityCode: "0123",
+ };
+ let methodData = dialogGlobal.paymentDialogWrapper.createBasicCardResponseData(
+ cardData
+ );
+
+ let responseData = {
+ acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_ACCEPTED,
+ methodName: "basic-card",
+ methodData,
+ payerName: "My Name",
+ payerEmail: "my.email@example.com",
+ payerPhone: "+15195555555",
+ };
+ let actual = dialogGlobal.paymentDialogWrapper.createShowResponse(
+ responseData
+ );
+ for (let [propName, propVal] of Object.entries(actual)) {
+ if (typeof propVal != "string") {
+ continue;
+ }
+ if (propName == "requestId") {
+ Assert.equal(propVal, requestId, `Check ${propName}`);
+ continue;
+ }
+ Assert.equal(propVal, responseData[propName], `Check ${propName}`);
+ }
+});
diff --git a/browser/components/payments/test/unit/xpcshell.ini b/browser/components/payments/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..b4f0db683a
--- /dev/null
+++ b/browser/components/payments/test/unit/xpcshell.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+firefox-appdir = browser
+head = head.js
+skip-if = true # Bug 1515048 - Disable for now.
+
+[test_response_creation.js]