diff options
Diffstat (limited to 'browser/components/payments/test')
51 files changed, 14685 insertions, 0 deletions
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] |