diff options
Diffstat (limited to 'browser/components/payments')
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®exp=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 + // ( 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,
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] |