summaryrefslogtreecommitdiffstats
path: root/browser/components/payments/test/mochitest
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/payments/test/mochitest')
-rw-r--r--browser/components/payments/test/mochitest/formautofill/mochitest.ini10
-rw-r--r--browser/components/payments/test/mochitest/formautofill/test_editCreditCard.html34
-rw-r--r--browser/components/payments/test/mochitest/mochitest.ini37
-rw-r--r--browser/components/payments/test/mochitest/payments_common.js154
-rw-r--r--browser/components/payments/test/mochitest/test_ObservedPropertiesMixin.html116
-rw-r--r--browser/components/payments/test/mochitest/test_PaymentStateSubscriberMixin.html79
-rw-r--r--browser/components/payments/test/mochitest/test_PaymentsStore.html168
-rw-r--r--browser/components/payments/test/mochitest/test_accepted_cards.html111
-rw-r--r--browser/components/payments/test/mochitest/test_address_form.html955
-rw-r--r--browser/components/payments/test/mochitest/test_address_option.html177
-rw-r--r--browser/components/payments/test/mochitest/test_address_picker.html278
-rw-r--r--browser/components/payments/test/mochitest/test_basic_card_form.html623
-rw-r--r--browser/components/payments/test/mochitest/test_basic_card_option.html96
-rw-r--r--browser/components/payments/test/mochitest/test_billing_address_picker.html132
-rw-r--r--browser/components/payments/test/mochitest/test_completion_error_page.html88
-rw-r--r--browser/components/payments/test/mochitest/test_currency_amount.html160
-rw-r--r--browser/components/payments/test/mochitest/test_labelled_checkbox.html71
-rw-r--r--browser/components/payments/test/mochitest/test_order_details.html215
-rw-r--r--browser/components/payments/test/mochitest/test_payer_address_picker.html323
-rw-r--r--browser/components/payments/test/mochitest/test_payment_details_item.html65
-rw-r--r--browser/components/payments/test/mochitest/test_payment_dialog.html360
-rw-r--r--browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html252
-rw-r--r--browser/components/payments/test/mochitest/test_payment_method_picker.html279
-rw-r--r--browser/components/payments/test/mochitest/test_rich_select.html150
-rw-r--r--browser/components/payments/test/mochitest/test_shipping_option_picker.html180
25 files changed, 5113 insertions, 0 deletions
diff --git a/browser/components/payments/test/mochitest/formautofill/mochitest.ini b/browser/components/payments/test/mochitest/formautofill/mochitest.ini
new file mode 100644
index 0000000000..9740f9e3e8
--- /dev/null
+++ b/browser/components/payments/test/mochitest/formautofill/mochitest.ini
@@ -0,0 +1,10 @@
+[DEFAULT]
+# This manifest mostly exists so that the support-files below can be referenced
+# from a relative path of formautofill/* from the tests in the above directory
+# to resemble the layout in the shipped JAR file.
+support-files =
+ ../../../../../../browser/extensions/formautofill/content/editCreditCard.xhtml
+ ../../../../../../browser/extensions/formautofill/content/editAddress.xhtml
+
+skip-if = true # Bug 1446164
+[test_editCreditCard.html]
diff --git a/browser/components/payments/test/mochitest/formautofill/test_editCreditCard.html b/browser/components/payments/test/mochitest/formautofill/test_editCreditCard.html
new file mode 100644
index 0000000000..4d4a06e35a
--- /dev/null
+++ b/browser/components/payments/test/mochitest/formautofill/test_editCreditCard.html
@@ -0,0 +1,34 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that editCreditCard.xhtml is accessible for tests in the parent directory.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test that editCreditCard.xhtml is accessible</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <iframe id="editCreditCard" src="editCreditCard.xhtml"></iframe>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="application/javascript">
+
+add_task(async function test_editCreditCard() {
+ let editCreditCard = document.getElementById("editCreditCard").contentWindow;
+ await SimpleTest.promiseFocus(editCreditCard);
+ ok(editCreditCard.document.getElementById("form"), "Check form is present");
+ ok(editCreditCard.document.getElementById("cc-number"), "Check cc-number is present");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/mochitest.ini b/browser/components/payments/test/mochitest/mochitest.ini
new file mode 100644
index 0000000000..d1d2907496
--- /dev/null
+++ b/browser/components/payments/test/mochitest/mochitest.ini
@@ -0,0 +1,37 @@
+[DEFAULT]
+support-files =
+ !/browser/extensions/formautofill/content/editAddress.xhtml
+ !/browser/extensions/formautofill/content/editCreditCard.xhtml
+ ../../../../../browser/extensions/formautofill/content/autofillEditForms.js
+ ../../../../../browser/extensions/formautofill/skin/shared/editDialog-shared.css
+ ../../../../../testing/modules/sinon-7.2.7.js
+ # paymentRequest.xhtml is needed for `importDialogDependencies` so that the relative paths of
+ # formautofill/edit*.xhtml work from the *-form elements in paymentRequest.xhtml.
+ ../../res/paymentRequest.xhtml
+ ../../res/**
+ payments_common.js
+skip-if = true || !e10s # Bug 1515048 - Disable for now. Bug 1365964 - Payment Request isn't implemented for non-e10s.
+
+[test_accepted_cards.html]
+[test_address_form.html]
+[test_address_option.html]
+skip-if = os == "linux" || os == "win" # Bug 1493216
+[test_address_picker.html]
+[test_basic_card_form.html]
+skip-if = debug || asan # Bug 1493349
+[test_basic_card_option.html]
+[test_billing_address_picker.html]
+[test_completion_error_page.html]
+[test_currency_amount.html]
+[test_labelled_checkbox.html]
+[test_order_details.html]
+[test_payer_address_picker.html]
+[test_payment_dialog.html]
+[test_payment_dialog_required_top_level_items.html]
+[test_payment_details_item.html]
+[test_payment_method_picker.html]
+[test_rich_select.html]
+[test_shipping_option_picker.html]
+[test_ObservedPropertiesMixin.html]
+[test_PaymentsStore.html]
+[test_PaymentStateSubscriberMixin.html]
diff --git a/browser/components/payments/test/mochitest/payments_common.js b/browser/components/payments/test/mochitest/payments_common.js
new file mode 100644
index 0000000000..8e48585318
--- /dev/null
+++ b/browser/components/payments/test/mochitest/payments_common.js
@@ -0,0 +1,154 @@
+"use strict";
+
+/* exported asyncElementRendered, promiseStateChange, promiseContentToChromeMessage, deepClone,
+ PTU, registerConsoleFilter, fillField, importDialogDependencies */
+
+const PTU = SpecialPowers.Cu.import(
+ "resource://testing-common/PaymentTestUtils.jsm",
+ {}
+).PaymentTestUtils;
+
+/**
+ * A helper to await on while waiting for an asynchronous rendering of a Custom
+ * Element.
+ * @returns {Promise}
+ */
+function asyncElementRendered() {
+ return Promise.resolve();
+}
+
+function promiseStateChange(store) {
+ return new Promise(resolve => {
+ store.subscribe({
+ stateChangeCallback(state) {
+ store.unsubscribe(this);
+ resolve(state);
+ },
+ });
+ });
+}
+
+/**
+ * Wait for a message of `messageType` from content to chrome and resolve with the event details.
+ * @param {string} messageType of the expected message
+ * @returns {Promise} when the message is dispatched
+ */
+function promiseContentToChromeMessage(messageType) {
+ return new Promise(resolve => {
+ document.addEventListener("paymentContentToChrome", function onCToC(event) {
+ if (event.detail.messageType != messageType) {
+ return;
+ }
+ document.removeEventListener("paymentContentToChrome", onCToC);
+ resolve(event.detail);
+ });
+ });
+}
+
+/**
+ * Import the templates and stylesheets from the real shipping dialog to avoid
+ * duplication in the tests.
+ * @param {HTMLIFrameElement} templateFrame - Frame to copy the resources from
+ * @param {HTMLElement} destinationEl - Where to append the copied resources
+ */
+function importDialogDependencies(templateFrame, destinationEl) {
+ let templates = templateFrame.contentDocument.querySelectorAll("template");
+ isnot(templates, null, "Check some templates found");
+ for (let template of templates) {
+ let imported = document.importNode(template, true);
+ destinationEl.appendChild(imported);
+ }
+
+ let baseURL = new URL("../../res/", window.location.href);
+ let stylesheetLinks = templateFrame.contentDocument.querySelectorAll(
+ "link[rel~='stylesheet']"
+ );
+ for (let stylesheet of stylesheetLinks) {
+ let imported = document.importNode(stylesheet, true);
+ imported.href = new URL(imported.getAttribute("href"), baseURL);
+ destinationEl.appendChild(imported);
+ }
+}
+
+function deepClone(obj) {
+ return JSON.parse(JSON.stringify(obj));
+}
+
+/**
+ * @param {HTMLElement} field
+ * @param {string} value
+ * @note This is async in case we need to make it async to handle focus in the future.
+ * @note Keep in sync with the copy in head.js
+ */
+async function fillField(field, value) {
+ field.focus();
+ if (field.localName == "select") {
+ if (field.value == value) {
+ // Do nothing
+ return;
+ }
+ field.value = value;
+ field.dispatchEvent(new Event("input", { bubbles: true }));
+ field.dispatchEvent(new Event("change", { bubbles: true }));
+ return;
+ }
+ while (field.value) {
+ sendKey("BACK_SPACE");
+ }
+ sendString(value);
+}
+
+/**
+ * If filterFunction is a function which returns true given a console message
+ * then the test won't fail from that message.
+ */
+let filterFunction = null;
+function registerConsoleFilter(filterFn) {
+ filterFunction = filterFn;
+}
+
+// Listen for errors to fail tests
+SpecialPowers.registerConsoleListener(function onConsoleMessage(msg) {
+ if (
+ msg.isWarning ||
+ !msg.errorMessage ||
+ msg.errorMessage == "paymentRequest.xhtml:"
+ ) {
+ // Ignore warnings and non-errors.
+ return;
+ }
+ if (
+ msg.category == "CSP_CSPViolationWithURI" &&
+ msg.errorMessage.includes("at inline")
+ ) {
+ // Ignore unknown CSP error.
+ return;
+ }
+ if (
+ msg.message &&
+ msg.message.includes("Security Error: Content at http://mochi.test:8888")
+ ) {
+ // Check for same-origin policy violations and ignore specific errors
+ if (
+ msg.message.includes("icon-credit-card-generic.svg") ||
+ msg.message.includes("accepted-cards.css") ||
+ msg.message.includes("editDialog-shared.css") ||
+ msg.message.includes("editAddress.css") ||
+ msg.message.includes("editDialog.css") ||
+ msg.message.includes("editCreditCard.css")
+ ) {
+ return;
+ }
+ }
+ if (msg.message == "SENTINEL") {
+ filterFunction = null;
+ }
+ if (filterFunction && filterFunction(msg)) {
+ return;
+ }
+ ok(false, msg.message || msg.errorMessage);
+});
+
+SimpleTest.registerCleanupFunction(function cleanup() {
+ SpecialPowers.postConsoleSentinel();
+});
diff --git a/browser/components/payments/test/mochitest/test_ObservedPropertiesMixin.html b/browser/components/payments/test/mochitest/test_ObservedPropertiesMixin.html
new file mode 100644
index 0000000000..00dff76a5f
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_ObservedPropertiesMixin.html
@@ -0,0 +1,116 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the ObservedPropertiesMixin
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the ObservedPropertiesMixin</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <test-element id="el1" one="foo" two-word="bar"></test-element>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the ObservedPropertiesMixin **/
+
+import ObservedPropertiesMixin from "../../res/mixins/ObservedPropertiesMixin.js";
+
+class TestElement extends ObservedPropertiesMixin(HTMLElement) {
+ static get observedAttributes() {
+ return ["one", "two-word"];
+ }
+
+ render() {
+ this.textContent = JSON.stringify({
+ one: this.one,
+ twoWord: this.twoWord,
+ });
+ }
+}
+
+customElements.define("test-element", TestElement);
+let el1 = document.getElementById("el1");
+
+add_task(async function test_default_properties() {
+ is(el1.one, "foo", "Check .one matches @one");
+ is(el1.twoWord, "bar", "Check .twoWord matches @two-word");
+ let expected = `{"one":"foo","twoWord":"bar"}`;
+ is(el1.textContent, expected, "Check textContent");
+});
+
+add_task(async function test_set_properties() {
+ el1.one = "a";
+ el1.twoWord = "b";
+ is(el1.one, "a", "Check .one value");
+ is(el1.getAttribute("one"), "a", "Check @one");
+ is(el1.twoWord, "b", "Check .twoWord value");
+ is(el1.getAttribute("two-word"), "b", "Check @two-word");
+ let expected = `{"one":"a","twoWord":"b"}`;
+ await asyncElementRendered();
+ is(el1.textContent, expected, "Check textContent");
+});
+
+add_task(async function test_set_attributes() {
+ el1.setAttribute("one", "X");
+ el1.setAttribute("two-word", "Y");
+ is(el1.one, "X", "Check .one value");
+ is(el1.getAttribute("one"), "X", "Check @one");
+ is(el1.twoWord, "Y", "Check .twoWord value");
+ is(el1.getAttribute("two-word"), "Y", "Check @two-word");
+ let expected = `{"one":"X","twoWord":"Y"}`;
+ await asyncElementRendered();
+ is(el1.textContent, expected, "Check textContent");
+});
+
+add_task(async function test_async_render() {
+ // Setup
+ el1.setAttribute("one", "1");
+ el1.setAttribute("two-word", "2");
+ await asyncElementRendered(); // Wait for the async render
+
+ el1.setAttribute("one", "new1");
+
+ is(el1.one, "new1", "Check .one value");
+ is(el1.getAttribute("one"), "new1", "Check @one");
+ is(el1.twoWord, "2", "Check .twoWord value");
+ is(el1.getAttribute("two-word"), "2", "Check @two-word");
+ let expected = `{"one":"1","twoWord":"2"}`;
+ is(el1.textContent, expected, "Check textContent is still old value due to async rendering");
+ await asyncElementRendered();
+ expected = `{"one":"new1","twoWord":"2"}`;
+ is(el1.textContent, expected, "Check textContent now has the new value");
+});
+
+add_task(async function test_batched_render() {
+ // Setup
+ el1.setAttribute("one", "1");
+ el1.setAttribute("two-word", "2");
+ await asyncElementRendered();
+
+ el1.setAttribute("one", "new1");
+ el1.setAttribute("two-word", "new2");
+
+ is(el1.one, "new1", "Check .one value");
+ is(el1.getAttribute("one"), "new1", "Check @one");
+ is(el1.twoWord, "new2", "Check .twoWord value");
+ is(el1.getAttribute("two-word"), "new2", "Check @two-word");
+ let expected = `{"one":"1","twoWord":"2"}`;
+ is(el1.textContent, expected, "Check textContent is still old value due to async rendering");
+ await asyncElementRendered();
+ expected = `{"one":"new1","twoWord":"new2"}`;
+ is(el1.textContent, expected, "Check textContent now has the new value");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_PaymentStateSubscriberMixin.html b/browser/components/payments/test/mochitest/test_PaymentStateSubscriberMixin.html
new file mode 100644
index 0000000000..73e3786288
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_PaymentStateSubscriberMixin.html
@@ -0,0 +1,79 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the PaymentStateSubscriberMixin
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the PaymentStateSubscriberMixin</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <test-element id="el1"></test-element>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the PaymentStateSubscriberMixin **/
+
+/* global sinon */
+
+import PaymentStateSubscriberMixin from "../../res/mixins/PaymentStateSubscriberMixin.js";
+
+class TestElement extends PaymentStateSubscriberMixin(HTMLElement) {
+ render(state) {
+ this.textContent = JSON.stringify(state);
+ }
+}
+
+// We must spy on the prototype by creating the instance in order to test Custom Element reactions.
+sinon.spy(TestElement.prototype, "disconnectedCallback");
+
+customElements.define("test-element", TestElement);
+let el1 = document.getElementById("el1");
+
+sinon.spy(el1, "render");
+sinon.spy(el1, "stateChangeCallback");
+
+add_task(async function test_initialState() {
+ let parsedState = JSON.parse(el1.textContent);
+ ok(!!parsedState.request, "Check initial state contains `request`");
+ ok(!!parsedState.savedAddresses, "Check initial state contains `savedAddresses`");
+ ok(!!parsedState.savedBasicCards, "Check initial state contains `savedBasicCards`");
+});
+
+add_task(async function test_async_batched_render() {
+ el1.requestStore.setState({a: 1});
+ el1.requestStore.setState({b: 2});
+ await asyncElementRendered();
+ ok(el1.stateChangeCallback.calledOnce, "stateChangeCallback called once");
+ ok(el1.render.calledOnce, "render called once");
+
+ let parsedState = JSON.parse(el1.textContent);
+ is(parsedState.a, 1, "Check a");
+ is(parsedState.b, 2, "Check b");
+});
+
+add_task(async function test_disconnect() {
+ el1.disconnectedCallback.reset();
+ el1.render.reset();
+ el1.stateChangeCallback.reset();
+ el1.remove();
+ ok(el1.disconnectedCallback.calledOnce, "disconnectedCallback called once");
+ await el1.requestStore.setState({a: 3});
+ await asyncElementRendered();
+ ok(el1.stateChangeCallback.notCalled, "stateChangeCallback not called");
+ ok(el1.render.notCalled, "render not called");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_PaymentsStore.html b/browser/components/payments/test/mochitest/test_PaymentsStore.html
new file mode 100644
index 0000000000..26f29e96ba
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_PaymentsStore.html
@@ -0,0 +1,168 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the PaymentsStore
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the PaymentsStore</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the PaymentsStore **/
+
+/* global sinon */
+
+import PaymentsStore from "../../res/PaymentsStore.js";
+
+function assert_throws(block, expectedError, message) {
+ let actual;
+ try {
+ block();
+ } catch (e) {
+ actual = e;
+ }
+ ok(actual, "Expecting exception: " + message);
+ ok(actual instanceof expectedError,
+ `Check error type is ${expectedError.prototype.name}: ${message}`);
+}
+
+add_task(async function test_defaultState() {
+ ok(!!PaymentsStore, "Check PaymentsStore import");
+ let ps = new PaymentsStore({
+ foo: "bar",
+ });
+
+ let state = ps.getState();
+ ok(!!state, "Check state is truthy");
+ is(state.foo, "bar", "Check .foo");
+
+ assert_throws(() => state.foo = "new", TypeError, "Assigning to existing prop. should throw");
+ assert_throws(() => state.other = "something", TypeError, "Adding a new prop. should throw");
+ assert_throws(() => delete state.foo, TypeError, "Deleting a prop. should throw");
+});
+
+add_task(async function test_setState() {
+ let ps = new PaymentsStore({});
+
+ ps.setState({
+ one: "one",
+ });
+ let state = ps.getState();
+ is(Object.keys(state).length, 1, "Should only have 1 prop. set");
+ is(state.one, "one", "Check .one");
+
+ ps.setState({
+ two: 2,
+ });
+ state = ps.getState();
+ is(Object.keys(state).length, 2, "Should have 2 props. set");
+ is(state.one, "one", "Check .one");
+ is(state.two, 2, "Check .two");
+
+ ps.setState({
+ one: "a",
+ two: "b",
+ });
+ state = ps.getState();
+ is(state.one, "a", "Check .one");
+ is(state.two, "b", "Check .two");
+
+ info("check consecutive setState for the same prop");
+ ps.setState({
+ one: "c",
+ });
+ ps.setState({
+ one: "d",
+ });
+ state = ps.getState();
+ is(Object.keys(state).length, 2, "Should have 2 props. set");
+ is(state.one, "d", "Check .one");
+ is(state.two, "b", "Check .two");
+});
+
+add_task(async function test_subscribe_unsubscribe() {
+ let ps = new PaymentsStore({});
+ let subscriber = {
+ stateChangePromise: null,
+ _stateChangeResolver: null,
+
+ reset() {
+ this.stateChangePromise = new Promise(resolve => {
+ this._stateChangeResolver = resolve;
+ });
+ },
+
+ stateChangeCallback(state) {
+ this._stateChangeResolver(state);
+ this.stateChangePromise = new Promise(resolve => {
+ this._stateChangeResolver = resolve;
+ });
+ },
+ };
+
+ sinon.spy(subscriber, "stateChangeCallback");
+ subscriber.reset();
+ ps.subscribe(subscriber);
+ info("subscribe the same listener twice to ensure it still doesn't call the callback");
+ ps.subscribe(subscriber);
+ ok(subscriber.stateChangeCallback.notCalled,
+ "Check not called synchronously when subscribing");
+
+ let changePromise = subscriber.stateChangePromise;
+ ps.setState({
+ a: 1,
+ });
+ ok(subscriber.stateChangeCallback.notCalled,
+ "Check not called synchronously for changes");
+ let state = await changePromise;
+ is(state, subscriber.stateChangeCallback.getCall(0).args[0],
+ "Check resolved state is last state");
+ is(JSON.stringify(state), `{"a":1}`, "Check callback state");
+
+ info("Testing consecutive setState");
+ subscriber.reset();
+ subscriber.stateChangeCallback.reset();
+ changePromise = subscriber.stateChangePromise;
+ ps.setState({
+ a: 2,
+ });
+ ps.setState({
+ a: 3,
+ });
+ ok(subscriber.stateChangeCallback.notCalled,
+ "Check not called synchronously for changes");
+ state = await changePromise;
+ is(state, subscriber.stateChangeCallback.getCall(0).args[0],
+ "Check resolved state is last state");
+ is(JSON.stringify(subscriber.stateChangeCallback.getCall(0).args[0]), `{"a":3}`,
+ "Check callback state matches second setState");
+
+ info("test unsubscribe");
+ subscriber.stateChangeCallback = function unexpectedChange() {
+ ok(false, "stateChangeCallback shouldn't be called after unsubscribing");
+ };
+ ps.unsubscribe(subscriber);
+ ps.setState({
+ a: 4,
+ });
+ await Promise.resolve("giving a chance for the callback to be called");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_accepted_cards.html b/browser/components/payments/test/mochitest/test_accepted_cards.html
new file mode 100644
index 0000000000..8e1da1bf3c
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_accepted_cards.html
@@ -0,0 +1,111 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the accepted-cards element
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the accepted-cards element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/accepted-cards.css"/>
+</head>
+<body>
+ <p id="display">
+ <accepted-cards label="Accepted:"></accepted-cards>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the accepted-cards component **/
+
+/* global sinon, PaymentDialogUtils */
+
+import "../../res/components/accepted-cards.js";
+import {requestStore} from "../../res/mixins/PaymentStateSubscriberMixin.js";
+let emptyState = requestStore.getState();
+let acceptedElem = document.querySelector("accepted-cards");
+let allNetworks = PaymentDialogUtils.getCreditCardNetworks();
+
+add_task(async function test_reConnected() {
+ let itemsCount = acceptedElem.querySelectorAll(".accepted-cards-item").length;
+ is(itemsCount, allNetworks.length, "Same number of items as there are supported networks");
+
+ let container = acceptedElem.parentNode;
+ let removed = container.removeChild(acceptedElem);
+ container.appendChild(removed);
+ let newItemsCount = acceptedElem.querySelectorAll(".accepted-cards-item").length;
+ is(itemsCount, newItemsCount, "Number of items doesnt changed when re-connected");
+});
+
+add_task(async function test_someAccepted() {
+ let supportedNetworks = ["discover", "amex"];
+ let paymentMethods = [{
+ supportedMethods: "basic-card",
+ data: {
+ supportedNetworks,
+ },
+ }];
+ requestStore.setState({
+ request: Object.assign({}, emptyState.request, {
+ paymentMethods,
+ }),
+ });
+ await asyncElementRendered();
+
+ let showingItems = acceptedElem.querySelectorAll(".accepted-cards-item:not([hidden])");
+ is(showingItems.length, 2,
+ "Expected 2 items to be showing when 2 supportedNetworks are indicated");
+ for (let network of allNetworks) {
+ if (supportedNetworks.includes(network)) {
+ ok(acceptedElem.querySelector(`[data-network-id='${network}']:not([hidden])`),
+ `Item for the ${network} network expected to be visible`);
+ } else {
+ ok(acceptedElem.querySelector(`[data-network-id='${network}'][hidden]`),
+ `Item for the ${network} network expected to be hidden`);
+ }
+ }
+});
+
+add_task(async function test_officialBranding() {
+ // verify we get the expected result when isOfficialBranding returns true
+ sinon.stub(PaymentDialogUtils, "isOfficialBranding").callsFake(() => { return true; });
+
+ let container = acceptedElem.parentNode;
+ let removed = container.removeChild(acceptedElem);
+ container.appendChild(removed);
+
+ ok(PaymentDialogUtils.isOfficialBranding.calledOnce,
+ "isOfficialBranding was called");
+ ok(acceptedElem.classList.contains("branded"),
+ "The branded class is added when isOfficialBranding returns true");
+ PaymentDialogUtils.isOfficialBranding.restore();
+
+ // verify we get the expected result when isOfficialBranding returns false
+ sinon.stub(PaymentDialogUtils, "isOfficialBranding").callsFake(() => { return false; });
+
+ // the branded class is toggled in the 'connectedCallback',
+ // so remove and re-add the element to re-evaluate branded-ness
+ removed = container.removeChild(acceptedElem);
+ container.appendChild(removed);
+
+ ok(PaymentDialogUtils.isOfficialBranding.calledOnce,
+ "isOfficialBranding was called");
+ ok(!acceptedElem.classList.contains("branded"),
+ "The branded class is removed when isOfficialBranding returns false");
+ PaymentDialogUtils.isOfficialBranding.restore();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_address_form.html b/browser/components/payments/test/mochitest/test_address_form.html
new file mode 100644
index 0000000000..906e5cc0f5
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_address_form.html
@@ -0,0 +1,955 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the address-form element
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the address-form element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="editDialog-shared.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/containers/address-form.css"/>
+</head>
+<body>
+ <p id="display">
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the address-form element **/
+
+/* global sinon, PaymentDialogUtils */
+
+import AddressForm from "../../res/containers/address-form.js";
+
+let display = document.getElementById("display");
+
+function checkAddressForm(customEl, expectedAddress) {
+ const ADDRESS_PROPERTY_NAMES = [
+ "given-name",
+ "family-name",
+ "organization",
+ "street-address",
+ "address-level2",
+ "address-level1",
+ "postal-code",
+ "country",
+ "email",
+ "tel",
+ ];
+ for (let propName of ADDRESS_PROPERTY_NAMES) {
+ let expectedVal = expectedAddress[propName] || "";
+ is(document.getElementById(propName).value,
+ expectedVal.toString(),
+ `Check ${propName}`);
+ }
+}
+
+function sendStringAndCheckValidity(element, string, isValid) {
+ fillField(element, string);
+ ok(element.checkValidity() == isValid,
+ `${element.id} should be ${isValid ? "valid" : "invalid"} ("${string}")`);
+}
+
+add_task(async function test_initialState() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+
+ await form.requestStore.setState({
+ "test-page": {},
+ });
+
+ let {page} = form.requestStore.getState();
+ is(page.id, "payment-summary", "Check initial page");
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+ is(page.id, "payment-summary", "Check initial page after appending");
+
+ // :-moz-ui-invalid, unlike :invalid, only applies to fields showing the error outline.
+ let fieldsVisiblyInvalid = form.querySelectorAll(":-moz-ui-invalid");
+ is(fieldsVisiblyInvalid.length, 0, "Check no fields are visibly invalid on an empty 'add' form");
+
+ form.remove();
+});
+
+add_task(async function test_pageTitle() {
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ // the element can have all the data attributes. We'll add them all up front
+ let form = new AddressForm();
+ let id = "shipping-address-page";
+ form.id = id;
+ form.dataset.titleAdd = `Add Title`;
+ form.dataset.titleEdit = `Edit Title`;
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+
+ await form.promiseReady;
+ display.appendChild(form);
+
+ let newState = {
+ page: { id },
+ [id]: {},
+ savedAddresses: {
+ [address1.guid]: address1,
+ },
+ request: {
+ paymentDetails: {},
+ paymentOptions: { shippingOption: "shipping" },
+ },
+ };
+ await form.requestStore.setState(newState);
+ await asyncElementRendered();
+ is(form.pageTitleHeading.textContent, "Add Title", "Check 'add' title");
+
+ // test the 'edit' variation
+ newState = deepClone(newState);
+ newState[id].guid = address1.guid;
+ await form.requestStore.setState(newState);
+ await asyncElementRendered();
+ is(form.pageTitleHeading.textContent, "Edit Title", "Check 'edit' title");
+
+ form.remove();
+});
+
+add_task(async function test_backButton() {
+ let form = new AddressForm();
+ form.id = "test-page";
+ form.dataset.titleAdd = "Sample add page title";
+ form.dataset.backButtonLabel = "Back";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+
+ await form.promiseReady;
+ display.appendChild(form);
+
+ await form.requestStore.setState({
+ "test-page": {},
+ page: {
+ id: "test-page",
+ },
+ request: {
+ paymentDetails: {},
+ paymentOptions: {},
+ },
+ });
+ await asyncElementRendered();
+
+ let stateChangePromise = promiseStateChange(form.requestStore);
+ is(form.pageTitleHeading.textContent, "Sample add page title", "Check title");
+
+ is(form.backButton.textContent, "Back", "Check label");
+ form.backButton.scrollIntoView();
+ synthesizeMouseAtCenter(form.backButton, {});
+
+ let {page} = await stateChangePromise;
+ is(page.id, "payment-summary", "Check initial page after appending");
+
+ form.remove();
+});
+
+add_task(async function test_saveButton() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ form.dataset.nextButtonLabel = "Next";
+ form.dataset.errorGenericSave = "Generic error";
+ await form.promiseReady;
+ display.appendChild(form);
+ form.requestStore.setState({
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {},
+ });
+ await asyncElementRendered();
+
+ ok(form.saveButton.disabled, "Save button initially disabled");
+ fillField(form.form.querySelector("#given-name"), "Jaws");
+ fillField(form.form.querySelector("#family-name"), "Swaj");
+ fillField(form.form.querySelector("#organization"), "Allizom");
+ fillField(form.form.querySelector("#street-address"), "404 Internet Super Highway");
+ fillField(form.form.querySelector("#address-level2"), "Firefoxity City");
+ fillField(form.form.querySelector("#country"), "US");
+ fillField(form.form.querySelector("#address-level1"), "CA");
+ fillField(form.form.querySelector("#postal-code"), "00001");
+ fillField(form.form.querySelector("#tel"), "+15555551212");
+
+ ok(!form.saveButton.disabled, "Save button is enabled after filling");
+
+ info("blanking the street-address");
+ fillField(form.form.querySelector("#street-address"), "");
+ ok(form.saveButton.disabled, "Save button is disabled after blanking street-address");
+ form.form.querySelector("#street-address").blur();
+ let fieldsVisiblyInvalid = form.querySelectorAll(":-moz-ui-invalid");
+ is(fieldsVisiblyInvalid.length, 1, "Check 1 field visibly invalid after blanking and blur");
+ is(fieldsVisiblyInvalid[0].id, "street-address", "Check #street-address is visibly invalid");
+
+ fillField(form.form.querySelector("#street-address"), "404 Internet Super Highway");
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0, "Check no fields visibly invalid");
+ ok(!form.saveButton.disabled, "Save button is enabled after re-filling street-address");
+
+ fillField(form.form.querySelector("#country"), "CA");
+ ok(form.saveButton.disabled, "Save button is disabled after changing the country to Canada");
+ fillField(form.form.querySelector("#country"), "US");
+ ok(form.saveButton.disabled,
+ "Save button is disabled after changing the country back to US since address-level1 " +
+ "got cleared when changing countries");
+ fillField(form.form.querySelector("#address-level1"), "CA");
+ ok(!form.saveButton.disabled, "Save button is enabled after re-entering address-level1");
+
+ let messagePromise = promiseContentToChromeMessage("updateAutofillRecord");
+ is(form.saveButton.textContent, "Next", "Check label");
+ form.saveButton.scrollIntoView();
+ synthesizeMouseAtCenter(form.saveButton, {});
+
+ let details = await messagePromise;
+ ok(typeof(details.messageID) == "number" && details.messageID > 0, "Check messageID type");
+ delete details.messageID;
+ is(details.collectionName, "addresses", "Check collectionName");
+ isDeeply(details, {
+ collectionName: "addresses",
+ guid: undefined,
+ messageType: "updateAutofillRecord",
+ record: {
+ "given-name": "Jaws",
+ "family-name": "Swaj",
+ "additional-name": "",
+ "organization": "Allizom",
+ "street-address": "404 Internet Super Highway",
+ "address-level3": "",
+ "address-level2": "Firefoxity City",
+ "address-level1": "CA",
+ "postal-code": "00001",
+ "country": "US",
+ "tel": "+15555551212",
+ },
+ }, "Check event details for the message to chrome");
+ form.remove();
+});
+
+add_task(async function test_genericError() {
+ let form = new AddressForm();
+ form.id = "test-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.requestStore.setState({
+ page: {
+ id: "test-page",
+ error: "Generic Error",
+ },
+ });
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ ok(!isHidden(form.genericErrorText), "Error message should be visible");
+ is(form.genericErrorText.textContent, "Generic Error", "Check error message");
+ form.remove();
+});
+
+add_task(async function test_edit() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.dataset.updateButtonLabel = "Update";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ await form.requestStore.setState({
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ guid: address1.guid,
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ });
+ await asyncElementRendered();
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check no fields are visibly invalid on an 'edit' form with a complete address");
+ checkAddressForm(form, address1);
+
+ ok(!form.saveButton.disabled, "Save button should be enabled upon edit for a valid address");
+
+ info("test change to minimal record");
+ let minimalAddress = {
+ "given-name": address1["given-name"],
+ guid: "9gnjdhen46",
+ };
+ await form.requestStore.setState({
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ guid: minimalAddress.guid,
+ },
+ savedAddresses: {
+ [minimalAddress.guid]: deepClone(minimalAddress),
+ },
+ });
+ await asyncElementRendered();
+ is(form.saveButton.textContent, "Update", "Check label");
+ checkAddressForm(form, minimalAddress);
+ ok(form.saveButton.disabled, "Save button should be disabled if only the name is filled");
+ ok(form.querySelectorAll(":-moz-ui-invalid").length > 3,
+ "Check fields are visibly invalid on an 'edit' form with only the given-name filled");
+ is(form.querySelectorAll("#country:-moz-ui-invalid").length, 1,
+ "Check that the country `select` is marked as invalid");
+
+ info("change to no selected address");
+ await form.requestStore.setState({
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {},
+ });
+ await asyncElementRendered();
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check no fields are visibly invalid on an empty 'add' form after being an edit form");
+ checkAddressForm(form, {
+ country: "US",
+ });
+ ok(form.saveButton.disabled, "Save button should be disabled for an empty form");
+
+ form.remove();
+});
+
+add_task(async function test_restricted_address_fields() {
+ let form = new AddressForm();
+ form.id = "payer-address-page";
+ form.setAttribute("selected-state-key", "selectedPayerAddress");
+ form.dataset.errorGenericSave = "Generic error";
+ form.dataset.fieldRequiredSymbol = "*";
+ form.dataset.nextButtonLabel = "Next";
+ await form.promiseReady;
+ form.form.dataset.extraRequiredFields = "name email tel";
+ display.appendChild(form);
+ await form.requestStore.setState({
+ page: {
+ id: "payer-address-page",
+ },
+ "payer-address-page": {
+ addressFields: "name email tel",
+ },
+ });
+ await asyncElementRendered();
+
+ ok(form.saveButton.disabled, "Save button should be disabled due to empty fields");
+
+ ok(!isHidden(form.form.querySelector("#given-name")),
+ "given-name should be visible");
+ ok(!isHidden(form.form.querySelector("#additional-name")),
+ "additional-name should be visible");
+ ok(!isHidden(form.form.querySelector("#family-name")),
+ "family-name should be visible");
+ ok(isHidden(form.form.querySelector("#organization")),
+ "organization should be hidden");
+ ok(isHidden(form.form.querySelector("#street-address")),
+ "street-address should be hidden");
+ ok(isHidden(form.form.querySelector("#address-level2")),
+ "address-level2 should be hidden");
+ ok(isHidden(form.form.querySelector("#address-level1")),
+ "address-level1 should be hidden");
+ ok(isHidden(form.form.querySelector("#postal-code")),
+ "postal-code should be hidden");
+ ok(isHidden(form.form.querySelector("#country")),
+ "country should be hidden");
+ ok(!isHidden(form.form.querySelector("#email")),
+ "email should be visible");
+ let telField = form.form.querySelector("#tel");
+ ok(!isHidden(telField),
+ "tel should be visible");
+ let telContainer = telField.closest(`#${telField.id}-container`);
+ ok(telContainer.hasAttribute("required"), "tel container should have required attribute");
+ let telSpan = telContainer.querySelector("span");
+ is(telSpan.getAttribute("fieldRequiredSymbol"), "*",
+ "tel span should have asterisk as fieldRequiredSymbol");
+ is(getComputedStyle(telSpan, "::after").content, "attr(fieldRequiredSymbol)",
+ "Asterisk should be on tel");
+
+ fillField(form.form.querySelector("#given-name"), "John");
+ fillField(form.form.querySelector("#family-name"), "Smith");
+ ok(form.saveButton.disabled, "Save button should be disabled due to empty fields");
+ fillField(form.form.querySelector("#email"), "john@example.com");
+ ok(form.saveButton.disabled,
+ "Save button should be disabled due to empty fields");
+ fillField(form.form.querySelector("#tel"), "+15555555555");
+ ok(!form.saveButton.disabled, "Save button should be enabled with all required fields filled");
+
+ form.remove();
+ await form.requestStore.setState({
+ "payer-address-page": {},
+ });
+});
+
+add_task(async function test_field_validation() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ form.dataset.fieldRequiredSymbol = "*";
+ await form.promiseReady;
+ display.appendChild(form);
+ await form.requestStore.setState({
+ page: {
+ id: "shipping-address-page",
+ },
+ });
+ await asyncElementRendered();
+
+ let postalCodeInput = form.form.querySelector("#postal-code");
+ let addressLevel1Input = form.form.querySelector("#address-level1");
+ ok(!postalCodeInput.value, "postal-code should be empty by default");
+ ok(!addressLevel1Input.value, "address-level1 should be empty by default");
+ ok(!postalCodeInput.checkValidity(), "postal-code should be invalid by default");
+ ok(!addressLevel1Input.checkValidity(), "address-level1 should be invalid by default");
+
+ let countrySelect = form.form.querySelector("#country");
+ let requiredFields = [
+ form.form.querySelector("#given-name"),
+ form.form.querySelector("#street-address"),
+ form.form.querySelector("#address-level2"),
+ postalCodeInput,
+ addressLevel1Input,
+ countrySelect,
+ ];
+ for (let field of requiredFields) {
+ let container = field.closest(`#${field.id}-container`);
+ ok(container.hasAttribute("required"), `#${field.id} container should have required attribute`);
+ let span = container.querySelector("span");
+ is(span.getAttribute("fieldRequiredSymbol"), "*",
+ "span should have asterisk as fieldRequiredSymbol");
+ is(getComputedStyle(span, "::after").content, "attr(fieldRequiredSymbol)",
+ "Asterisk should be on " + field.id);
+ }
+
+ ok(form.saveButton.disabled, "Save button should be disabled upon load");
+
+ fillField(countrySelect, "US");
+
+ sendStringAndCheckValidity(addressLevel1Input, "MI", true);
+ sendStringAndCheckValidity(addressLevel1Input, "", false);
+ sendStringAndCheckValidity(postalCodeInput, "B4N4N4", false);
+ sendStringAndCheckValidity(addressLevel1Input, "NS", false);
+ sendStringAndCheckValidity(postalCodeInput, "R3J 3C7", false);
+ sendStringAndCheckValidity(addressLevel1Input, "", false);
+ sendStringAndCheckValidity(postalCodeInput, "11109", true);
+ sendStringAndCheckValidity(addressLevel1Input, "NS", false);
+ sendStringAndCheckValidity(postalCodeInput, "06390-0001", true);
+
+ fillField(countrySelect, "CA");
+
+ sendStringAndCheckValidity(postalCodeInput, "00001", false);
+ sendStringAndCheckValidity(addressLevel1Input, "CA", false);
+ sendStringAndCheckValidity(postalCodeInput, "94043", false);
+ sendStringAndCheckValidity(addressLevel1Input, "", false);
+ sendStringAndCheckValidity(postalCodeInput, "B4N4N4", true);
+ sendStringAndCheckValidity(addressLevel1Input, "MI", false);
+ sendStringAndCheckValidity(postalCodeInput, "R3J 3C7", true);
+ sendStringAndCheckValidity(addressLevel1Input, "", false);
+ sendStringAndCheckValidity(postalCodeInput, "11109", false);
+ sendStringAndCheckValidity(addressLevel1Input, "NS", true);
+ sendStringAndCheckValidity(postalCodeInput, "06390-0001", false);
+
+ form.remove();
+});
+
+add_task(async function test_merchantShippingAddressErrors() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+
+ // Merchant errors only make sense when editing a record so add one.
+ let address1 = deepClone(PTU.Addresses.TimBR);
+ address1.guid = "9864798564";
+
+ const state = {
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ guid: address1.guid,
+ },
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {
+ addressLine: "Street address needs to start with a D",
+ city: "City needs to start with a B",
+ country: "Country needs to start with a C",
+ dependentLocality: "Can only be SUBURBS, not NEIGHBORHOODS",
+ organization: "organization needs to start with an A",
+ phone: "Telephone needs to start with a 9",
+ postalCode: "Postal code needs to start with a 0",
+ recipient: "Name needs to start with a Z",
+ region: "Region needs to start with a Y",
+ regionCode: "Regions must be 1 to 3 characters in length (sometimes ;) )",
+ },
+ },
+ paymentOptions: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ };
+ display.appendChild(form);
+ await form.requestStore.setState(state);
+ await asyncElementRendered();
+
+ function checkValidationMessage(selector, property) {
+ let expected = state.request.paymentDetails.shippingAddressErrors[property];
+ let container = form.form.querySelector(selector + "-container");
+ ok(!isHidden(container), selector + "-container should be visible");
+ is(form.form.querySelector(selector).validationMessage,
+ expected,
+ "Validation message should match for " + selector);
+ }
+
+ ok(form.saveButton.disabled, "Save button should be disabled due to validation errors");
+
+ checkValidationMessage("#street-address", "addressLine");
+ checkValidationMessage("#address-level2", "city");
+ checkValidationMessage("#address-level3", "dependentLocality");
+ checkValidationMessage("#country", "country");
+ checkValidationMessage("#organization", "organization");
+ checkValidationMessage("#tel", "phone");
+ checkValidationMessage("#postal-code", "postalCode");
+ checkValidationMessage("#given-name", "recipient");
+ checkValidationMessage("#address-level1", "regionCode");
+ isnot(form.form.querySelector("#address-level1"),
+ state.request.paymentDetails.shippingAddressErrors.region,
+ "When both region and regionCode are supplied we only show the 'regionCode' error");
+
+ // TODO: bug 1482808 - the save button should be enabled after editing the fields
+
+ form.remove();
+});
+
+add_task(async function test_customMerchantValidity_reset() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+
+ // Merchant errors only make sense when editing a record so add one.
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ const state = {
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ guid: address1.guid,
+ },
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {
+ addressLine: "Street address needs to start with a D",
+ city: "City needs to start with a B",
+ country: "Country needs to start with a C",
+ organization: "organization needs to start with an A",
+ phone: "Telephone needs to start with a 9",
+ postalCode: "Postal code needs to start with a 0",
+ recipient: "Name needs to start with a Z",
+ region: "Region needs to start with a Y",
+ },
+ },
+ paymentOptions: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ };
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ ok(!!form.querySelectorAll(":-moz-ui-invalid").length, "Check fields are visibly invalid");
+ info("merchant cleared the errors");
+ await form.requestStore.setState({
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {},
+ },
+ paymentOptions: {},
+ },
+ });
+ await asyncElementRendered();
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check fields are visibly valid - custom validity cleared");
+
+ form.remove();
+});
+
+add_task(async function test_customMerchantValidity_shippingAddressForm() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+
+ // Merchant errors only make sense when editing a record so add one.
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ const state = {
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ guid: address1.guid,
+ },
+ request: {
+ paymentDetails: {
+ billingAddressErrors: {
+ addressLine: "Billing Street address needs to start with a D",
+ city: "Billing City needs to start with a B",
+ country: "Billing Country needs to start with a C",
+ organization: "Billing organization needs to start with an A",
+ phone: "Billing Telephone needs to start with a 9",
+ postalCode: "Billing Postal code needs to start with a 0",
+ recipient: "Billing Name needs to start with a Z",
+ region: "Billing Region needs to start with a Y",
+ },
+ },
+ paymentOptions: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ };
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check fields are visibly valid - billing errors are not relevant to a shipping address form");
+
+ // now switch in some shipping address errors
+ await form.requestStore.setState({
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {
+ addressLine: "Street address needs to start with a D",
+ city: "City needs to start with a B",
+ country: "Country needs to start with a C",
+ organization: "organization needs to start with an A",
+ phone: "Telephone needs to start with a 9",
+ postalCode: "Postal code needs to start with a 0",
+ recipient: "Name needs to start with a Z",
+ region: "Region needs to start with a Y",
+ },
+ },
+ paymentOptions: {},
+ },
+ });
+ await asyncElementRendered();
+
+ ok(form.querySelectorAll(":-moz-ui-invalid").length >= 8, "Check fields are visibly invalid");
+});
+
+add_task(async function test_customMerchantValidity_billingAddressForm() {
+ let form = new AddressForm();
+ form.id = "billing-address-page";
+ form.setAttribute("selected-state-key", "basic-card-page|billingAddressGUID");
+ await form.promiseReady;
+
+ // Merchant errors only make sense when editing a record so add one.
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ const state = {
+ page: {
+ id: "billing-address-page",
+ },
+ "billing-address-page": {
+ guid: address1.guid,
+ },
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {
+ addressLine: "Street address needs to start with a D",
+ city: "City needs to start with a B",
+ country: "Country needs to start with a C",
+ organization: "organization needs to start with an A",
+ phone: "Telephone needs to start with a 9",
+ postalCode: "Postal code needs to start with a 0",
+ recipient: "Name needs to start with a Z",
+ region: "Region needs to start with a Y",
+ },
+ },
+ paymentOptions: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ };
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check fields are visibly valid - shipping errors are not relevant to a billing address form");
+
+ await form.requestStore.setState({
+ request: {
+ paymentDetails: {
+ paymentMethodErrors: {
+ billingAddress: {
+ addressLine: "Billing Street address needs to start with a D",
+ city: "Billing City needs to start with a B",
+ country: "Billing Country needs to start with a C",
+ organization: "Billing organization needs to start with an A",
+ phone: "Billing Telephone needs to start with a 9",
+ postalCode: "Billing Postal code needs to start with a 0",
+ recipient: "Billing Name needs to start with a Z",
+ region: "Billing Region needs to start with a Y",
+ },
+ },
+ },
+ paymentOptions: {},
+ },
+ });
+ await asyncElementRendered();
+ ok(form.querySelectorAll(":-moz-ui-invalid").length >= 8,
+ "Check billing fields are visibly invalid");
+
+ form.remove();
+});
+
+add_task(async function test_merchantPayerAddressErrors() {
+ let form = new AddressForm();
+ form.id = "payer-address-page";
+ form.setAttribute("selected-state-key", "selectedPayerAddress");
+
+ await form.promiseReady;
+ form.form.dataset.extraRequiredFields = "name email tel";
+
+ // Merchant errors only make sense when editing a record so add one.
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ const state = {
+ page: {
+ id: "payer-address-page",
+ },
+ "payer-address-page": {
+ addressFields: "name email tel",
+ guid: address1.guid,
+ },
+ request: {
+ paymentDetails: {
+ payerErrors: {
+ email: "Email must be @mozilla.org",
+ name: "Name needs to start with a W",
+ phone: "Telephone needs to start with a 1",
+ },
+ },
+ paymentOptions: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ };
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ function checkValidationMessage(selector, property) {
+ is(form.form.querySelector(selector).validationMessage,
+ state.request.paymentDetails.payerErrors[property],
+ "Validation message should match for " + selector);
+ }
+
+ ok(form.saveButton.disabled, "Save button should be disabled due to validation errors");
+
+ checkValidationMessage("#tel", "phone");
+ checkValidationMessage("#family-name", "name");
+ checkValidationMessage("#email", "email");
+
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 3, "Check payer fields are visibly invalid");
+
+ await form.requestStore.setState({
+ request: {
+ paymentDetails: {
+ payerErrors: {},
+ },
+ paymentOptions: {},
+ },
+ });
+ await asyncElementRendered();
+
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check payer fields are visibly valid after clearing merchant errors");
+
+ form.remove();
+});
+
+add_task(async function test_field_validation() {
+ let getFormFormatStub = sinon.stub(PaymentDialogUtils, "getFormFormat");
+ getFormFormatStub.returns({
+ addressLevel1Label: "state",
+ postalCodeLabel: "US",
+ fieldsOrder: [
+ {fieldId: "name", newLine: true},
+ {fieldId: "organization", newLine: true},
+ {fieldId: "street-address", newLine: true},
+ {fieldId: "address-level2"},
+ ],
+ });
+
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+ const state = {
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ },
+ request: {
+ paymentDetails: {
+ shippingAddressErrors: {},
+ },
+ paymentOptions: {},
+ },
+ };
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ ok(form.saveButton.disabled, "Save button should be disabled due to empty fields");
+
+ let postalCodeInput = form.form.querySelector("#postal-code");
+ let addressLevel1Input = form.form.querySelector("#address-level1");
+ ok(!postalCodeInput.value, "postal-code should be empty by default");
+ ok(!addressLevel1Input.value, "address-level1 should be empty by default");
+ ok(postalCodeInput.checkValidity(),
+ "postal-code should be valid by default when it is not visible");
+ ok(addressLevel1Input.checkValidity(),
+ "address-level1 should be valid by default when it is not visible");
+
+ getFormFormatStub.restore();
+ form.remove();
+});
+
+add_task(async function test_field_validation_dom_popup() {
+ let form = new AddressForm();
+ form.id = "shipping-address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ await form.promiseReady;
+ const state = {
+ page: {
+ id: "shipping-address-page",
+ },
+ "shipping-address-page": {
+ },
+ };
+
+ await form.requestStore.setState(state);
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ const BAD_POSTAL_CODE = "hi mom";
+ let postalCode = form.querySelector("#postal-code");
+ postalCode.focus();
+ sendString(BAD_POSTAL_CODE, window);
+ postalCode.blur();
+ let errorTextSpan = postalCode.parentNode.querySelector(".error-text");
+ is(errorTextSpan.textContent, "Please match the requested format.",
+ "DOM validation messages should be reflected in the error-text #1");
+
+ postalCode.focus();
+ while (postalCode.value) {
+ sendKey("BACK_SPACE", window);
+ }
+ postalCode.blur();
+ is(errorTextSpan.textContent, "Please fill out this field.",
+ "DOM validation messages should be reflected in the error-text #2");
+
+ postalCode.focus();
+ sendString("12345", window);
+ is(errorTextSpan.innerText, "", "DOM validation message should be removed when no error");
+ postalCode.blur();
+
+ form.remove();
+});
+
+add_task(async function test_hiddenMailingAddressFieldsCleared() {
+ let form = new AddressForm();
+ form.id = "address-page";
+ form.setAttribute("selected-state-key", "selectedShippingAddress");
+ form.dataset.updateButtonLabel = "Update";
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ let address1 = deepClone(PTU.Addresses.TimBL);
+ address1.guid = "9864798564";
+
+ await form.requestStore.setState({
+ page: {
+ id: "address-page",
+ },
+ "address-page": {
+ guid: address1.guid,
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ });
+ await asyncElementRendered();
+
+ info("Change the country to hide address-level1");
+ fillField(form.form.querySelector("#country"), "DE");
+
+ let expectedRecord = Object.assign({}, address1, {
+ country: "DE",
+ // address-level1 & 3 aren't used for Germany so should be blanked.
+ "address-level1": "",
+ "address-level3": "",
+ });
+ delete expectedRecord.guid;
+ // The following were not shown so shouldn't be part of the message:
+ delete expectedRecord.email;
+
+ let messagePromise = promiseContentToChromeMessage("updateAutofillRecord");
+ form.saveButton.scrollIntoView();
+ synthesizeMouseAtCenter(form.saveButton, {});
+
+ info("Waiting for messagePromise");
+ let details = await messagePromise;
+ info("/Waiting for messagePromise");
+ delete details.messageID;
+ is(details.collectionName, "addresses", "Check collectionName");
+ isDeeply(details, {
+ collectionName: "addresses",
+ guid: address1.guid,
+ messageType: "updateAutofillRecord",
+ record: expectedRecord,
+ }, "Check update event details for the message to chrome");
+
+ form.remove();
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_address_option.html b/browser/components/payments/test/mochitest/test_address_option.html
new file mode 100644
index 0000000000..670208a775
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_address_option.html
@@ -0,0 +1,177 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the address-option component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the address-option component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <option id="option1"
+ data-field-separator=", "
+ address-level1="MI"
+ address-level2="Some City"
+ country="US"
+ email="foo@bar.com"
+ name="John Smith"
+ postal-code="90210"
+ street-address="123 Sesame Street,&#xA;Apt 40"
+ tel="+1 519 555-5555"
+ value="option1"
+ guid="option1"></option>
+ <option id="option2"
+ data-field-separator=", "
+ value="option2"
+ guid="option2"></option>
+
+ <rich-select id="richSelect1"
+ option-type="address-option"></rich-select>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the address-option component **/
+
+import "../../res/components/address-option.js";
+import "../../res/components/rich-select.js";
+
+let option1 = document.getElementById("option1");
+let option2 = document.getElementById("option2");
+let richSelect1 = document.getElementById("richSelect1");
+
+add_task(async function test_populated_option_rendering() {
+ richSelect1.popupBox.appendChild(option1);
+ richSelect1.value = option1.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+
+ is(richOption.name, "John Smith", "Check name getter");
+ is(richOption.streetAddress, "123 Sesame Street,\nApt 40", "Check streetAddress getter");
+ is(richOption.addressLevel2, "Some City", "Check addressLevel2 getter");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+ ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+ is(richOption._line1.innerText, "John Smith, 123 Sesame Street, Apt 40", "Line 1 text");
+ is(richOption._line2.innerText, "Some City, MI, 90210, US", "Line 2 text");
+
+ // Note that innerText takes visibility into account so that's why it's used over textContent here
+ is(richOption._name.innerText, "John Smith", "name text");
+ is(richOption["_street-address"].innerText, "123 Sesame Street, Apt 40", "street-address text");
+ is(richOption["_address-level2"].innerText, "Some City", "address-level2 text");
+
+ is(richOption._email.parentElement, null,
+ "Check email field isn't in the document for a mailing-address option");
+});
+
+// Same option as the last test but with @break-after-nth-field=1
+add_task(async function test_breakAfterNthField() {
+ richSelect1.popupBox.appendChild(option1);
+ richSelect1.value = option1.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ richOption.breakAfterNthField = 1;
+ await asyncElementRendered();
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+ ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+ is(richOption._line1.innerText, "John Smith", "Line 1 text with breakAfterNthField = 1");
+ is(richOption._line2.innerText, "123 Sesame Street, Apt 40, Some City, MI, 90210, US",
+ "Line 2 text with breakAfterNthField = 1");
+});
+
+add_task(async function test_addressField_mailingAddress() {
+ richSelect1.popupBox.appendChild(option1);
+ richSelect1.value = option1.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ richOption.addressFields = "mailing-address";
+ await asyncElementRendered();
+ is(richOption.getAttribute("address-fields"), "mailing-address", "Check @address-fields");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+ ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+ is(richOption._line1.innerText, "John Smith, 123 Sesame Street, Apt 40", "Line 1 text");
+ is(richOption._line2.innerText, "Some City, MI, 90210, US", "Line 2 text");
+
+ ok(!isHidden(richOption._line2), "Line 2 should be visible when it's used");
+
+ is(richOption._email.parentElement, null,
+ "Check email field isn't in the document for a mailing-address option");
+});
+
+add_task(async function test_addressField_nameEmail() {
+ richSelect1.popupBox.appendChild(option1);
+ richSelect1.value = option1.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ richOption.addressFields = "name email";
+ await asyncElementRendered();
+ is(richOption.getAttribute("address-fields"), "name email", "Check @address-fields");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+ ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+ is(richOption._line1.innerText, "John Smith, foo@bar.com", "Line 1 text");
+ is(richOption._line2.innerText, "", "Line 2 text");
+
+ ok(isHidden(richOption._line2), "Line 2 should be hidden when it's not used");
+
+ isnot(richOption._email.parentElement, null,
+ "Check email field is in the document for a 'name email' option");
+});
+
+add_task(async function test_missing_fields_option_rendering() {
+ richSelect1.popupBox.appendChild(option2);
+ richSelect1.value = option2.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ is(richOption.name, null, "Check name getter");
+ is(richOption.streetAddress, null, "Check streetAddress getter");
+ is(richOption.addressLevel2, null, "Check addressLevel2 getter");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ is(richOption._name.innerText, "", "name text");
+ is(window.getComputedStyle(richOption._name, "::before").content, "attr(data-missing-string)",
+ "Check missing field pseudo content");
+ is(richOption._name.getAttribute("data-missing-string"), "Name Missing",
+ "Check @data-missing-string");
+ is(richOption._email.parentElement, null,
+ "Check email field isn't in the document for a mailing-address option");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_address_picker.html b/browser/components/payments/test/mochitest/test_address_picker.html
new file mode 100644
index 0000000000..5a3f1b398a
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_address_picker.html
@@ -0,0 +1,278 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the address-picker component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the address-picker component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <address-picker id="picker1"
+ data-field-separator=", "
+ data-invalid-label="Picker1: Missing or Invalid"
+ selected-state-key="selectedShippingAddress"></address-picker>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the address-picker component **/
+
+import "../../res/containers/address-picker.js";
+
+let picker1 = document.getElementById("picker1");
+
+add_task(async function test_empty() {
+ ok(picker1, "Check picker1 exists");
+ let {savedAddresses} = picker1.requestStore.getState();
+ is(Object.keys(savedAddresses).length, 0, "Check empty initial state");
+ is(picker1.editLink.hidden, true, "Check that picker edit link is hidden");
+ is(picker1.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
+});
+
+add_task(async function test_initialSet() {
+ picker1.requestStore.setState({
+ savedAddresses: {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ "country": "US",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ "tel": "+1 519 555-5555",
+ timeLastUsed: 200,
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ timeLastUsed: 300,
+ },
+ "abcde12345": {
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "abcde12345",
+ "name": "Mrs. Fields",
+ timeLastUsed: 100,
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 3, "Check dropdown has all addresses");
+ ok(options[0].textContent.includes("Mrs. Bar"), "Check first address based on timeLastUsed");
+ ok(options[1].textContent.includes("Mr. Foo"), "Check second address based on timeLastUsed");
+ ok(options[2].textContent.includes("Mrs. Fields"), "Check third address based on timeLastUsed");
+});
+
+add_task(async function test_update() {
+ picker1.requestStore.setState({
+ savedAddresses: {
+ "48bnds6854t": {
+ // Same GUID, different values to trigger an update
+ "address-level1": "MI-edit",
+ // address-level2 was cleared which means it's not returned
+ "country": "CA",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo-edit",
+ "postal-code": "90210-1234",
+ "street-address": "new-edit",
+ "tel": "+1 650 555-5555",
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ },
+ "abcde12345": {
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "abcde12345",
+ "name": "Mrs. Fields",
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 3, "Check dropdown still has all addresses");
+ ok(options[0].textContent.includes("Mr. Foo-edit"), "Check updated name in first address");
+ ok(!options[0].getAttribute("address-level2"), "Check removed first address-level2");
+ ok(options[1].textContent.includes("Mrs. Bar"), "Check that name is the same in second address");
+ ok(options[1].getAttribute("street-address").includes("P.O. Box 123"),
+ "Check second address is the same");
+ ok(options[2].textContent.includes("Mrs. Fields"),
+ "Check that name is the same in third address");
+ is(options[2].getAttribute("street-address"), null, "Check third address is missing");
+});
+
+add_task(async function test_change_selected_address() {
+ let options = picker1.dropdown.popupBox.children;
+ is(picker1.dropdown.selectedOption, null, "Should default to no selected option");
+ is(picker1.editLink.hidden, true, "Picker edit link should be hidden when no option is selected");
+ let {selectedShippingAddress} = picker1.requestStore.getState();
+ is(selectedShippingAddress, null, "store should have no option selected");
+ ok(!picker1.classList.contains("invalid-selected-option"), "No validation on an empty selection");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey(options[2].getAttribute("name"), {});
+ await asyncElementRendered();
+
+ let selectedOption = picker1.dropdown.selectedOption;
+ is(selectedOption, options[2], "Selected option should now be the third option");
+ selectedShippingAddress = picker1.requestStore.getState().selectedShippingAddress;
+ is(selectedShippingAddress, selectedOption.getAttribute("guid"),
+ "store should have third option selected");
+ // The third option is missing some fields. Make sure that it is marked as such.
+ ok(picker1.classList.contains("invalid-selected-option"), "The third option is missing fields");
+ ok(!isHidden(picker1.invalidLabel), "The invalid label should be visible");
+ is(picker1.invalidLabel.innerText, picker1.dataset.invalidLabel, "Check displayed error text");
+
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey(options[1].getAttribute("name"), {});
+ await asyncElementRendered();
+
+ selectedOption = picker1.dropdown.selectedOption;
+ is(selectedOption, options[1], "Selected option should now be the second option");
+ selectedShippingAddress = picker1.requestStore.getState().selectedShippingAddress;
+ is(selectedShippingAddress, selectedOption.getAttribute("guid"),
+ "store should have second option selected");
+ ok(!picker1.classList.contains("invalid-selected-option"), "The second option has all fields");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+});
+
+add_task(async function test_address_combines_name_street_level2_level1_postalCode_country() {
+ let options = picker1.dropdown.popupBox.children;
+ let richoption1 = picker1.dropdown.querySelector(".rich-select-selected-option");
+ /* eslint-disable max-len */
+ is(richoption1.innerText,
+ `${options[1].getAttribute("name")}, ${options[1].getAttribute("street-address")}
+${options[1].getAttribute("address-level2")}, ${options[1].getAttribute("address-level1")}, ${options[1].getAttribute("postal-code")}, ${options[1].getAttribute("country")}`,
+ "The address shown should be human readable and include all fields");
+ /* eslint-enable max-len */
+
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey(options[2].getAttribute("name"), {});
+ await asyncElementRendered();
+
+ richoption1 = picker1.dropdown.querySelector(".rich-select-selected-option");
+ // "Missing …" text is rendered via a pseudo element content and isn't included in innerText
+ is(richoption1.innerText, "Mrs. Fields, \nMountain View, , US",
+ "The address shown should be human readable and include all fields");
+
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey(options[1].getAttribute("name"), {});
+ await asyncElementRendered();
+});
+
+add_task(async function test_delete() {
+ picker1.requestStore.setState({
+ savedAddresses: {
+ // 48bnds6854t and abcde12345 was deleted
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 1, "Check dropdown has one remaining address");
+ ok(options[0].textContent.includes("Mrs. Bar"), "Check remaining address");
+});
+
+add_task(async function test_merchantError() {
+ picker1.requestStore.setState({
+ selectedShippingAddress: "68gjdh354j",
+ });
+ await asyncElementRendered();
+
+ is(picker1.selectedStateKey, "selectedShippingAddress", "Check selectedStateKey");
+
+ let state = picker1.requestStore.getState();
+ let {
+ request,
+ } = state;
+ ok(!picker1.classList.contains("invalid-selected-option"), "No validation on a valid option");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+
+ let requestWithShippingAddressErrors = deepClone(request);
+ Object.assign(requestWithShippingAddressErrors.paymentDetails, {
+ shippingAddressErrors: {
+ country: "Your country is not supported",
+ },
+ });
+ picker1.requestStore.setState({
+ request: requestWithShippingAddressErrors,
+ });
+ await asyncElementRendered();
+
+ ok(picker1.classList.contains("invalid-selected-option"), "The merchant error applies");
+ ok(!isHidden(picker1.invalidLabel), "The merchant error should be visible");
+ is(picker1.invalidLabel.innerText, "Your country is not supported", "Check displayed error text");
+
+ info("update the request to remove the errors");
+ picker1.requestStore.setState({
+ request,
+ });
+ await asyncElementRendered();
+ ok(!picker1.classList.contains("invalid-selected-option"),
+ "No errors visible when merchant errors cleared");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+
+ info("Set billing address and payer errors which aren't relevant to this picker");
+ let requestWithNonShippingAddressErrors = deepClone(request);
+ Object.assign(requestWithNonShippingAddressErrors.paymentDetails, {
+ payerErrors: {
+ name: "Your name is too short",
+ },
+ paymentMethodErrors: {
+ billingAddress: {
+ country: "Your billing country is not supported",
+ },
+ },
+ shippingAddressErrors: {},
+ });
+ picker1.requestStore.setState({
+ request: requestWithNonShippingAddressErrors,
+ });
+ await asyncElementRendered();
+ ok(!picker1.classList.contains("invalid-selected-option"), "No errors on a shipping picker");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should still be hidden");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_basic_card_form.html b/browser/components/payments/test/mochitest/test_basic_card_form.html
new file mode 100644
index 0000000000..239e3b013d
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_basic_card_form.html
@@ -0,0 +1,623 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the basic-card-form element
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the basic-card-form element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/accepted-cards.css"/>
+</head>
+<body>
+ <p id="display" style="height: 100vh; margin: 0;">
+ <iframe id="templateFrame" src="paymentRequest.xhtml" width="0" height="0"
+ sandbox="allow-same-origin"
+ style="float: left;"></iframe>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the basic-card-form element **/
+
+import BasicCardForm from "../../res/containers/basic-card-form.js";
+
+let display = document.getElementById("display");
+let supportedNetworks = ["discover", "amex"];
+let paymentMethods = [{
+ supportedMethods: "basic-card",
+ data: {
+ supportedNetworks,
+ },
+}];
+
+function checkCCForm(customEl, expectedCard) {
+ const CC_PROPERTY_NAMES = [
+ "billingAddressGUID",
+ "cc-number",
+ "cc-name",
+ "cc-exp-month",
+ "cc-exp-year",
+ "cc-type",
+ ];
+ for (let propName of CC_PROPERTY_NAMES) {
+ let expectedVal = expectedCard[propName] || "";
+ is(document.getElementById(propName).value,
+ expectedVal.toString(),
+ `Check ${propName}`);
+ }
+}
+
+function createAddressRecord(source, props = {}) {
+ let address = Object.assign({}, source, props);
+ if (!address.name) {
+ address.name = `${address["given-name"]} ${address["family-name"]}`;
+ }
+ return address;
+}
+
+add_task(async function setup_once() {
+ let templateFrame = document.getElementById("templateFrame");
+ await SimpleTest.promiseFocus(templateFrame.contentWindow);
+ let displayEl = document.getElementById("display");
+ importDialogDependencies(templateFrame, displayEl);
+});
+
+add_task(async function test_initialState() {
+ let form = new BasicCardForm();
+
+ await form.requestStore.setState({
+ savedAddresses: {
+ "TimBLGUID": createAddressRecord(PTU.Addresses.TimBL),
+ },
+ });
+
+ let {page} = form.requestStore.getState();
+ is(page.id, "payment-summary", "Check initial page");
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+ is(page.id, "payment-summary", "Check initial page after appending");
+
+ // :-moz-ui-invalid, unlike :invalid, only applies to fields showing the error outline.
+ let fieldsVisiblyInvalid = form.querySelectorAll(":-moz-ui-invalid");
+ for (let field of fieldsVisiblyInvalid) {
+ info("invalid field: " + field.localName + "#" + field.id + "." + field.className);
+ }
+ is(fieldsVisiblyInvalid.length, 0, "Check no fields are visibly invalid on an empty 'add' form");
+
+ form.remove();
+});
+
+add_task(async function test_backButton() {
+ let form = new BasicCardForm();
+ form.dataset.backButtonLabel = "Back";
+ form.dataset.addBasicCardTitle = "Sample page title 2";
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ selectedStateKey: "selectedPaymentCard",
+ },
+ });
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ let stateChangePromise = promiseStateChange(form.requestStore);
+ is(form.pageTitleHeading.textContent, "Sample page title 2", "Check title");
+ is(form.backButton.textContent, "Back", "Check label");
+ form.backButton.scrollIntoView();
+ synthesizeMouseAtCenter(form.backButton, {});
+
+ let {page} = await stateChangePromise;
+ is(page.id, "payment-summary", "Check initial page after appending");
+
+ form.remove();
+});
+
+add_task(async function test_saveButton() {
+ let form = new BasicCardForm();
+ form.dataset.nextButtonLabel = "Next";
+ form.dataset.errorGenericSave = "Generic error";
+ form.dataset.invalidAddressLabel = "Invalid";
+
+ await form.promiseReady;
+ display.appendChild(form);
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, {guid: "TimBLGUID"});
+ let address2 = createAddressRecord(PTU.Addresses.TimBL2, {guid: "TimBL2GUID"});
+
+ await form.requestStore.setState({
+ request: {
+ paymentMethods,
+ paymentDetails: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ [address2.guid]: deepClone(address2),
+ },
+ });
+
+ await asyncElementRendered();
+
+ // when merchant provides supportedNetworks, the accepted card list should be visible
+ ok(!form.acceptedCardsList.hidden, "Accepted card list should be visible when adding a card");
+
+ ok(form.saveButton.disabled, "Save button should initially be disabled");
+ fillField(form.form.querySelector("#cc-number"), "4111 1111-1111 1111");
+ form.form.querySelector("#cc-name").focus();
+ // Check .disabled after .focus() so that it's after both "input" and "change" events.
+ ok(form.saveButton.disabled, "Save button should still be disabled without a name");
+ sendString("J. Smith");
+ fillField(form.form.querySelector("#cc-exp-month"), "11");
+ let year = (new Date()).getFullYear().toString();
+ fillField(form.form.querySelector("#cc-exp-year"), year);
+ fillField(form.form.querySelector("#cc-type"), "visa");
+ fillField(form.form.querySelector("csc-input input"), "123");
+ isnot(form.form.querySelector("#billingAddressGUID").value, address2.guid,
+ "Check initial billing address");
+ fillField(form.form.querySelector("#billingAddressGUID"), address2.guid);
+ is(form.form.querySelector("#billingAddressGUID").value, address2.guid,
+ "Check selected billing address");
+ form.saveButton.focus();
+ ok(!form.saveButton.disabled,
+ "Save button should be enabled since the required fields are filled");
+
+ fillField(form.form.querySelector("#cc-exp-month"), "");
+ fillField(form.form.querySelector("#cc-exp-year"), "");
+ form.saveButton.focus();
+ ok(form.saveButton.disabled,
+ "Save button should be disabled since the required fields are empty");
+ fillField(form.form.querySelector("#cc-exp-month"), "11");
+ fillField(form.form.querySelector("#cc-exp-year"), year);
+ form.saveButton.focus();
+ ok(!form.saveButton.disabled,
+ "Save button should be enabled since the required fields are filled again");
+
+ info("blanking the cc-number field");
+ fillField(form.form.querySelector("#cc-number"), "");
+ ok(form.saveButton.disabled, "Save button is disabled after blanking cc-number");
+ form.form.querySelector("#cc-number").blur();
+ let fieldsVisiblyInvalid = form.querySelectorAll(":-moz-ui-invalid");
+ is(fieldsVisiblyInvalid.length, 1, "Check 1 field visibly invalid after blanking and blur");
+ is(fieldsVisiblyInvalid[0].id, "cc-number", "Check #cc-number is visibly invalid");
+
+ fillField(form.form.querySelector("#cc-number"), "4111 1111-1111 1111");
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0, "Check no fields visibly invalid");
+ ok(!form.saveButton.disabled, "Save button is enabled after re-filling cc-number");
+
+ let messagePromise = promiseContentToChromeMessage("updateAutofillRecord");
+ is(form.saveButton.textContent, "Next", "Check label");
+ form.saveButton.scrollIntoView();
+ synthesizeMouseAtCenter(form.saveButton, {});
+
+ let details = await messagePromise;
+ ok(typeof(details.messageID) == "number" && details.messageID > 0, "Check messageID type");
+ delete details.messageID;
+ is(details.collectionName, "creditCards", "Check collectionName");
+ isDeeply(details, {
+ collectionName: "creditCards",
+ guid: undefined,
+ messageType: "updateAutofillRecord",
+ record: {
+ "cc-exp-month": "11",
+ "cc-exp-year": year,
+ "cc-name": "J. Smith",
+ "cc-number": "4111 1111-1111 1111",
+ "cc-type": "visa",
+ "billingAddressGUID": address2.guid,
+ "isTemporary": true,
+ },
+ }, "Check event details for the message to chrome");
+ form.remove();
+});
+
+add_task(async function test_requiredAttributePropagated() {
+ let form = new BasicCardForm();
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ let requiredElements = [...form.form.elements].filter(e => e.required && !e.disabled);
+ is(requiredElements.length, 7, "Number of required elements");
+ for (let element of requiredElements) {
+ if (element.id == "billingAddressGUID") {
+ // The billing address has a different layout.
+ continue;
+ }
+ let container = element.closest("label") || element.closest("div");
+ ok(container.hasAttribute("required"),
+ `Container ${container.id} should also be marked as required`);
+ }
+ // Now test that toggling the `required` attribute will affect the container.
+ let sampleRequiredElement = requiredElements[0];
+ let sampleRequiredContainer = sampleRequiredElement.closest("label") ||
+ sampleRequiredElement.closest("div");
+ sampleRequiredElement.removeAttribute("required");
+ await form.requestStore.setState({});
+ await asyncElementRendered();
+ ok(!sampleRequiredElement.hasAttribute("required"),
+ `"required" attribute should still be removed from element (${sampleRequiredElement.id})`);
+ ok(!sampleRequiredContainer.hasAttribute("required"),
+ `"required" attribute should be removed from container`);
+ sampleRequiredElement.setAttribute("required", "true");
+ await form.requestStore.setState({});
+ await asyncElementRendered();
+ ok(sampleRequiredContainer.hasAttribute("required"),
+ "`required` attribute is re-added to container");
+
+ form.remove();
+});
+
+add_task(async function test_genericError() {
+ let form = new BasicCardForm();
+ await form.requestStore.setState({
+ page: {
+ id: "test-page",
+ error: "Generic Error",
+ },
+ });
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ ok(!isHidden(form.genericErrorText), "Error message should be visible");
+ is(form.genericErrorText.textContent, "Generic Error", "Check error message");
+ form.remove();
+});
+
+add_task(async function test_add_selectedShippingAddress() {
+ let form = new BasicCardForm();
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ info("have an existing card in storage");
+ let card1 = deepClone(PTU.BasicCards.JohnDoe);
+ card1.guid = "9864798564";
+ card1["cc-exp-year"] = 2011;
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, { guid: "TimBLGUID" });
+ let address2 = createAddressRecord(PTU.Addresses.TimBL2, { guid: "TimBL2GUID" });
+
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ [address2.guid]: deepClone(address2),
+ },
+ savedBasicCards: {
+ [card1.guid]: deepClone(card1),
+ },
+ selectedShippingAddress: address2.guid,
+ });
+ await asyncElementRendered();
+ checkCCForm(form, {
+ billingAddressGUID: address2.guid,
+ });
+
+ form.remove();
+ await form.requestStore.reset();
+});
+
+add_task(async function test_add_noSelectedShippingAddress() {
+ let form = new BasicCardForm();
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ info("have an existing card in storage but unused");
+ let card1 = deepClone(PTU.BasicCards.JohnDoe);
+ card1.guid = "9864798564";
+ card1["cc-exp-year"] = 2011;
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, { guid: "TimBLGUID" });
+
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ savedBasicCards: {
+ [card1.guid]: deepClone(card1),
+ },
+ selectedShippingAddress: null,
+ });
+ await asyncElementRendered();
+ checkCCForm(form, {
+ billingAddressGUID: address1.guid,
+ });
+
+ info("now test with a missing selectedShippingAddress");
+ await form.requestStore.setState({
+ selectedShippingAddress: "some-missing-guid",
+ });
+ await asyncElementRendered();
+ checkCCForm(form, {
+ billingAddressGUID: address1.guid,
+ });
+
+ form.remove();
+ await form.requestStore.reset();
+});
+
+add_task(async function test_edit() {
+ let form = new BasicCardForm();
+ form.dataset.updateButtonLabel = "Update";
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, { guid: "TimBLGUID" });
+
+ info("test year before current");
+ let card1 = deepClone(PTU.BasicCards.JohnDoe);
+ card1.guid = "9864798564";
+ card1["cc-exp-year"] = 2011;
+ card1.billingAddressGUID = address1.guid;
+
+ await form.requestStore.setState({
+ request: {
+ paymentMethods,
+ paymentDetails: {},
+ },
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ guid: card1.guid,
+ selectedStateKey: "selectedPaymentCard",
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ savedBasicCards: {
+ [card1.guid]: deepClone(card1),
+ },
+ });
+ await asyncElementRendered();
+ is(form.saveButton.textContent, "Update", "Check label");
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check no fields are visibly invalid on an 'edit' form with a complete card");
+
+ checkCCForm(form, card1);
+ ok(!form.saveButton.disabled, "Save button should be enabled upon edit for a valid card");
+ ok(!form.acceptedCardsList.hidden, "Accepted card list should be visible when editing a card");
+
+ let requiredElements = [...form.form.elements].filter(e => e.required && !e.disabled);
+ ok(requiredElements.length, "There should be at least one required element");
+ is(requiredElements.length, 5, "Number of required elements");
+ for (let element of requiredElements) {
+ if (element.id == "billingAddressGUID") {
+ // The billing address has a different layout.
+ continue;
+ }
+
+ let container = element.closest("label") || element.closest("div");
+ ok(element.hasAttribute("required"), "Element should be marked as required");
+ ok(container.hasAttribute("required"), "Container should also be marked as required");
+ }
+
+ info("test future year");
+ card1["cc-exp-year"] = 2100;
+
+ await form.requestStore.setState({
+ savedBasicCards: {
+ [card1.guid]: deepClone(card1),
+ },
+ });
+ await asyncElementRendered();
+ checkCCForm(form, card1);
+
+ info("test change to minimal record");
+ let minimalCard = {
+ // no expiration date or name
+ "cc-number": "1234567690123",
+ guid: "9gnjdhen46",
+ };
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ guid: minimalCard.guid,
+ selectedStateKey: "selectedPaymentCard",
+ },
+ savedBasicCards: {
+ [minimalCard.guid]: deepClone(minimalCard),
+ },
+ });
+ await asyncElementRendered();
+ ok(!!form.querySelectorAll(":-moz-ui-invalid").length,
+ "Check fields are visibly invalid on an 'edit' form with missing fields");
+ checkCCForm(form, minimalCard);
+
+ info("change to no selected card");
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ guid: null,
+ selectedStateKey: "selectedPaymentCard",
+ },
+ });
+ await asyncElementRendered();
+ is(form.querySelectorAll(":-moz-ui-invalid").length, 0,
+ "Check no fields are visibly invalid after converting to an 'add' form");
+ checkCCForm(form, {
+ billingAddressGUID: address1.guid, // Default selected
+ });
+
+ form.remove();
+});
+
+add_task(async function test_field_validity_updates() {
+ let form = new BasicCardForm();
+ form.dataset.updateButtonLabel = "Update";
+ await form.promiseReady;
+ display.appendChild(form);
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, {guid: "TimBLGUID"});
+ await form.requestStore.setState({
+ request: {
+ paymentMethods,
+ paymentDetails: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ });
+ await asyncElementRendered();
+
+ let ccNumber = form.form.querySelector("#cc-number");
+ let nameInput = form.form.querySelector("#cc-name");
+ let typeInput = form.form.querySelector("#cc-type");
+ let cscInput = form.form.querySelector("csc-input input");
+ let monthInput = form.form.querySelector("#cc-exp-month");
+ let yearInput = form.form.querySelector("#cc-exp-year");
+ let addressPicker = form.querySelector("#billingAddressGUID");
+
+ info("test with valid cc-number but missing cc-name");
+ fillField(ccNumber, "4111111111111111");
+ ok(ccNumber.checkValidity(), "cc-number field is valid with good input");
+ ok(!nameInput.checkValidity(), "cc-name field is invalid when empty");
+ ok(form.saveButton.disabled, "Save button should be disabled with incomplete input");
+
+ info("correct by adding cc-name and expiration values");
+ fillField(nameInput, "First");
+ fillField(monthInput, "11");
+ let year = (new Date()).getFullYear().toString();
+ fillField(yearInput, year);
+ fillField(typeInput, "visa");
+ fillField(cscInput, "456");
+ ok(ccNumber.checkValidity(), "cc-number field is valid with good input");
+ ok(nameInput.checkValidity(), "cc-name field is valid with a value");
+ ok(monthInput.checkValidity(), "cc-exp-month field is valid with a value");
+ ok(yearInput.checkValidity(), "cc-exp-year field is valid with a value");
+ ok(typeInput.checkValidity(), "cc-type field is valid with a value");
+
+ // should auto-select the first billing address
+ ok(addressPicker.value, "An address is selected: " + addressPicker.value);
+
+ let fieldsVisiblyInvalid = form.querySelectorAll(":-moz-ui-invalid");
+ for (let field of fieldsVisiblyInvalid) {
+ info("invalid field: " + field.localName + "#" + field.id + "." + field.className);
+ }
+ is(fieldsVisiblyInvalid.length, 0, "No fields are visibly invalid");
+
+ ok(!form.saveButton.disabled, "Save button should not be disabled with good input");
+
+ info("edit to make the cc-number invalid");
+ ccNumber.focus();
+ sendString("aa");
+ nameInput.focus();
+ sendString("Surname");
+
+ ok(!ccNumber.checkValidity(), "cc-number field becomes invalid with bad input");
+ ok(form.querySelector("#cc-number:-moz-ui-invalid"), "cc-number field is visibly invalid");
+ ok(nameInput.checkValidity(), "cc-name field is valid with a value");
+ ok(form.saveButton.disabled, "Save button becomes disabled with bad input");
+
+ info("fix the cc-number to make it all valid again");
+ ccNumber.focus();
+ sendKey("BACK_SPACE");
+ sendKey("BACK_SPACE");
+ info("after backspaces, ccNumber.value: " + ccNumber.value);
+
+ ok(ccNumber.checkValidity(), "cc-number field becomes valid with corrected input");
+ ok(nameInput.checkValidity(), "cc-name field is valid with a value");
+ ok(!form.saveButton.disabled, "Save button is no longer disabled with corrected input");
+
+ form.remove();
+});
+
+add_task(async function test_numberCustomValidityReset() {
+ let form = new BasicCardForm();
+ form.dataset.updateButtonLabel = "Update";
+ await form.promiseReady;
+ display.appendChild(form);
+
+ let address1 = createAddressRecord(PTU.Addresses.TimBL, {guid: "TimBLGUID"});
+ await form.requestStore.setState({
+ request: {
+ paymentMethods,
+ paymentDetails: {},
+ },
+ savedAddresses: {
+ [address1.guid]: deepClone(address1),
+ },
+ });
+ await asyncElementRendered();
+
+ fillField(form.querySelector("#cc-number"), "junk");
+ sendKey("TAB");
+ ok(form.querySelector("#cc-number:-moz-ui-invalid"), "cc-number field is visibly invalid");
+
+ info("simulate triggering an add again to reset the form");
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ selectedStateKey: "selectedPaymentCard",
+ },
+ });
+
+ ok(!form.querySelector("#cc-number:-moz-ui-invalid"), "cc-number field is not visibly invalid");
+
+ form.remove();
+});
+
+add_task(async function test_noCardNetworkSelected() {
+ let form = new BasicCardForm();
+ await form.promiseReady;
+ display.appendChild(form);
+ await asyncElementRendered();
+
+ info("have an existing card in storage, with no network id");
+ let card1 = deepClone(PTU.BasicCards.JohnDoe);
+ card1.guid = "9864798564";
+ delete card1["cc-type"];
+
+ await form.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ guid: card1.guid,
+ selectedStateKey: "selectedPaymentCard",
+ },
+ savedBasicCards: {
+ [card1.guid]: deepClone(card1),
+ },
+ });
+ await asyncElementRendered();
+ checkCCForm(form, card1);
+ is(document.getElementById("cc-type").selectedIndex, 0, "Initial empty option is selected");
+
+ form.remove();
+ await form.requestStore.reset();
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_basic_card_option.html b/browser/components/payments/test/mochitest/test_basic_card_option.html
new file mode 100644
index 0000000000..71a0199fec
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_basic_card_option.html
@@ -0,0 +1,96 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the basic-card-option component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the basic-card-option component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/basic-card-option.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <option id="option1"
+ value="option1"
+ cc-exp="2024-06"
+ cc-name="John Smith"
+ cc-number="************5461"
+ cc-type="visa"
+ guid="option1"></option>
+ <option id="option2"
+ value="option2"
+ cc-number="************1111"
+ guid="option2"></option>
+
+ <rich-select id="richSelect1"
+ option-type="basic-card-option"></rich-select>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the basic-card-option component **/
+
+import "../../res/components/basic-card-option.js";
+import "../../res/components/rich-select.js";
+
+let option1 = document.getElementById("option1");
+let option2 = document.getElementById("option2");
+let richSelect1 = document.getElementById("richSelect1");
+
+add_task(async function test_populated_option_rendering() {
+ richSelect1.popupBox.appendChild(option1);
+ richSelect1.value = option1.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ is(richOption.ccExp, "2024-06", "Check ccExp getter");
+ is(richOption.ccName, "John Smith", "Check ccName getter");
+ is(richOption.ccNumber, "************5461", "Check ccNumber getter");
+ is(richOption.ccType, "visa", "Check ccType getter");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ // Note that innerText takes visibility into account so that's why it's used over textContent here
+ is(richOption["_cc-exp"].innerText, "Exp. 2024-06", "cc-exp text");
+ is(richOption["_cc-name"].innerText, "John Smith", "cc-name text");
+ is(richOption["_cc-number"].innerText, "****5461", "cc-number text");
+ is(richOption["_cc-type"].localName, "img", "cc-type localName");
+ is(richOption["_cc-type"].alt, "visa", "cc-type img alt");
+});
+
+add_task(async function test_minimal_option_rendering() {
+ richSelect1.popupBox.appendChild(option2);
+ richSelect1.value = option2.value;
+ await asyncElementRendered();
+
+ let richOption = richSelect1.selectedRichOption;
+ is(richOption.ccExp, null, "Check ccExp getter");
+ is(richOption.ccName, null, "Check ccName getter");
+ is(richOption.ccNumber, "************1111", "Check ccNumber getter");
+ is(richOption.ccType, null, "Check ccType getter");
+
+ ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+ ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+ is(richOption["_cc-exp"].innerText, "", "cc-exp text");
+ is(richOption["_cc-name"].innerText, "", "cc-name text");
+ is(richOption["_cc-number"].innerText, "****1111", "cc-number text");
+ is(richOption["_cc-type"].localName, "img", "cc-type localName");
+ is(richOption["_cc-type"].alt, "", "cc-type img alt");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_billing_address_picker.html b/browser/components/payments/test/mochitest/test_billing_address_picker.html
new file mode 100644
index 0000000000..0039c97e55
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_billing_address_picker.html
@@ -0,0 +1,132 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the address-picker component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the billing-address-picker component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <billing-address-picker id="picker1"
+ data-field-separator=", "
+ data-invalid-label="Picker1: Missing or Invalid"
+ selected-state-key="basic-card-page|billingAddressGUID"></billing-address-picker>
+ <select id="theOptions">
+ <option></option>
+ <option value="48bnds6854t">48bnds6854t</option>
+ <option value="68gjdh354j" selected="">68gjdh354j</option>
+ </select>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the billing-address-picker component **/
+
+import BillingAddressPicker from "../../res/containers/billing-address-picker.js";
+
+let picker1 = document.getElementById("picker1");
+let addresses = {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ "country": "US",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ "tel": "+1 519 555-5555",
+ timeLastUsed: 200,
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ timeLastUsed: 300,
+ },
+ "abcde12345": {
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "abcde12345",
+ "name": "Mrs. Fields",
+ timeLastUsed: 100,
+ },
+};
+
+add_task(async function test_empty() {
+ ok(picker1, "Check picker1 exists");
+ let {savedAddresses} = picker1.requestStore.getState();
+ is(Object.keys(savedAddresses).length, 0, "Check empty initial state");
+ is(picker1.editLink.hidden, true, "Check that picker edit link is hidden");
+ is(picker1.options.length, 1, "Check only the empty option is present");
+ ok(picker1.dropdown.selectedOption, "Has a selectedOption");
+ is(picker1.dropdown.value, "", "Has empty value");
+
+ // update state to trigger render without changing available addresses
+ picker1.requestStore.setState({
+ "basic-card-page": {
+ "someKey": "someValue",
+ },
+ });
+ await asyncElementRendered();
+
+ is(picker1.dropdown.popupBox.children.length, 1, "Check only the empty option is present");
+ ok(picker1.dropdown.selectedOption, "Has a selectedOption");
+ is(picker1.dropdown.value, "", "Has empty value");
+});
+
+add_task(async function test_getCurrentValue() {
+ picker1.requestStore.setState({
+ "basic-card-page": {
+ "billingAddressGUID": "68gjdh354j",
+ },
+ savedAddresses: addresses,
+ });
+ await asyncElementRendered();
+
+ picker1.dropdown.popupBox.value = "abcde12345";
+
+ is(picker1.options.length, 4, "Check we have options for each address + empty one");
+ is(picker1.getCurrentValue(picker1.requestStore.getState()), "abcde12345",
+ "Initial/current value reflects the <select>.value, " +
+ "not whatever is in the state at the selectedStateKey");
+});
+
+add_task(async function test_wrapPopupBox() {
+ let picker = new BillingAddressPicker();
+ picker.dropdown.popupBox = document.querySelector("#theOptions");
+ picker.dataset.invalidLabel = "Invalid";
+ picker.setAttribute("label", "The label");
+ picker.setAttribute("selected-state-key", "basic-card-page|billingAddressGUID");
+
+ document.querySelector("#display").appendChild(picker);
+
+ is(picker.labelElement.getAttribute("for"), "theOptions",
+ "The label points at the right element");
+ is(picker.invalidLabel.getAttribute("for"), "theOptions",
+ "The invalidLabel points at the right element");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_completion_error_page.html b/browser/components/payments/test/mochitest/test_completion_error_page.html
new file mode 100644
index 0000000000..cb8e809758
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_completion_error_page.html
@@ -0,0 +1,88 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the completion-error-page component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the completion-error-page component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <completion-error-page id="completion-timeout-error" class="illustrated"
+ data-page-title="Sample Title"
+ data-suggestion-heading="Sample suggestion heading"
+ data-suggestion-1="Sample suggestion"
+ data-suggestion-2="Sample suggestion"
+ data-suggestion-3="Sample suggestion"
+ data-branding-label="Sample Brand"
+ data-done-button-label="OK"></completion-error-page>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the completion-error-page component **/
+
+import "../../res/containers/completion-error-page.js";
+
+let page = document.getElementById("completion-timeout-error");
+
+add_task(async function test_no_values() {
+ ok(page, "page exists");
+ is(page.dataset.pageTitle, "Sample Title", "Title set on page");
+ is(page.dataset.suggestionHeading, "Sample suggestion heading",
+ "Suggestion heading set on page");
+ is(page.dataset["suggestion-1"], "Sample suggestion",
+ "Suggestion 1 set on page");
+ is(page.dataset["suggestion-2"], "Sample suggestion",
+ "Suggestion 2 set on page");
+ is(page.dataset["suggestion-3"], "Sample suggestion",
+ "Suggestion 3 set on page");
+ is(page.dataset.brandingLabel, "Sample Brand", "Branding string set");
+
+ page.dataset.pageTitle = "Oh noes! **host-name** is having an issue";
+ page.dataset["suggestion-2"] = "You should probably blame **host-name**, not us";
+ const displayHost = "allizom.com";
+ let request = { topLevelPrincipal: { URI: { displayHost } } };
+ await page.requestStore.setState({
+ changesPrevented: false,
+ request: Object.assign({}, request, {completeStatus: ""}),
+ orderDetailsShowing: false,
+ page: {
+ id: "completion-timeout-error",
+ },
+ });
+ await asyncElementRendered();
+
+ is(page.requestStore.getState().request.topLevelPrincipal.URI.displayHost, displayHost,
+ "State should have the displayHost set properly");
+ is(page.querySelector("h2").textContent,
+ `Oh noes! ${displayHost} is having an issue`,
+ "Title includes host-name");
+ is(page.querySelector("p").textContent,
+ "Sample suggestion heading",
+ "Suggestion heading set on page");
+ is(page.querySelector("li:nth-child(1)").textContent, "Sample suggestion",
+ "Suggestion 1 set on page");
+ is(page.querySelector("li:nth-child(2)").textContent,
+ `You should probably blame ${displayHost}, not us`,
+ "Suggestion 2 includes host-name");
+ is(page.querySelector(".branding").textContent,
+ "Sample Brand",
+ "Branding set on page");
+ is(page.querySelector(".primary").textContent,
+ "OK",
+ "Primary button label set correctly");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_currency_amount.html b/browser/components/payments/test/mochitest/test_currency_amount.html
new file mode 100644
index 0000000000..dc1cbac8f2
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_currency_amount.html
@@ -0,0 +1,160 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the currency-amount component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the currency-amount component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <currency-amount id="amount1"></currency-amount>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the currency-amount component **/
+
+import "../../res/components/currency-amount.js";
+
+let amount1 = document.getElementById("amount1");
+
+add_task(async function test_no_value() {
+ ok(amount1, "amount1 exists");
+ is(amount1.textContent, "", "Initially empty");
+
+ amount1.currency = "USD";
+ await asyncElementRendered();
+ is(amount1.getAttribute("currency"), "USD", "Check @currency");
+ ok(!amount1.hasAttribute("value"), "Check @value");
+ is(amount1.currency, "USD", "Check .currency");
+ is(amount1.value, null, "Check .value");
+ is(amount1.textContent, "", "Empty while missing an amount");
+
+ amount1.currency = null;
+ await asyncElementRendered();
+ ok(!amount1.hasAttribute("currency"), "Setting to null should remove @currency");
+ ok(!amount1.hasAttribute("value"), "Check @value");
+ is(amount1.currency, null, "Check .currency");
+ is(amount1.value, null, "Check .value");
+});
+
+add_task(async function test_no_value() {
+ amount1.value = 1.23;
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "1.23", "Check @value");
+ ok(!amount1.hasAttribute("currency"), "Check @currency");
+ is(amount1.currency, null, "Check .currency");
+ is(amount1.value, "1.23", "Check .value");
+ is(amount1.textContent, "", "Empty while missing a currency");
+
+ amount1.value = null;
+ await asyncElementRendered();
+ ok(!amount1.hasAttribute("value"), "Setting to null should remove @value");
+ is(amount1.currency, null, "Check .currency");
+ is(amount1.value, null, "Check .value");
+});
+
+add_task(async function test_valid_currency_amount_cad() {
+ amount1.value = 12.34;
+ info("waiting to set second property");
+ await asyncElementRendered();
+ amount1.currency = "CAD";
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "12.34", "Check @value");
+ is(amount1.value, "12.34", "Check .value");
+ is(amount1.getAttribute("currency"), "CAD", "Check @currency");
+ is(amount1.currency, "CAD", "Check .currency");
+ is(amount1.textContent, "CA$12.34", "Check output format");
+});
+
+add_task(async function test_valid_currency_amount_displayCode() {
+ amount1.value = 12.34;
+ info("showing the currency code");
+ await asyncElementRendered();
+ amount1.currency = "CAD";
+ await asyncElementRendered();
+ amount1.displayCode = true;
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "12.34", "Check @value");
+ is(amount1.value, "12.34", "Check .value");
+ is(amount1.getAttribute("currency"), "CAD", "Check @currency");
+ is(amount1.currency, "CAD", "Check .currency");
+ is(amount1.textContent, "CA$12.34 CAD", "Check output format");
+
+ amount1.displayCode = false;
+ await asyncElementRendered();
+});
+
+
+add_task(async function test_valid_currency_amount_eur_batched_prop() {
+ info("setting two properties in a row synchronously");
+ amount1.value = 98.76;
+ amount1.currency = "EUR";
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "98.76", "Check @value");
+ is(amount1.value, "98.76", "Check .value");
+ is(amount1.getAttribute("currency"), "EUR", "Check @currency");
+ is(amount1.currency, "EUR", "Check .currency");
+ is(amount1.textContent, "€98.76", "Check output format");
+});
+
+add_task(async function test_valid_currency_amount_eur_batched_attr() {
+ info("setting two attributes in a row synchronously");
+ amount1.setAttribute("value", 11.88);
+ amount1.setAttribute("currency", "CAD");
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "11.88", "Check @value");
+ is(amount1.value, "11.88", "Check .value");
+ is(amount1.getAttribute("currency"), "CAD", "Check @currency");
+ is(amount1.currency, "CAD", "Check .currency");
+ is(amount1.textContent, "CA$11.88", "Check output format");
+});
+
+add_task(async function test_invalid_currency() {
+ isnot(amount1.textContent, "", "Start with initial content");
+ amount1.value = 33.33;
+ amount1.currency = "__invalid__";
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "33.33", "Check @value");
+ is(amount1.value, "33.33", "Check .value");
+ is(amount1.getAttribute("currency"), "__invalid__", "Check @currency");
+ is(amount1.currency, "__invalid__", "Check .currency");
+ is(amount1.textContent, "", "Invalid currency should clear output");
+});
+
+add_task(async function test_invalid_value() {
+ info("setting some initial values");
+ amount1.value = 4.56;
+ amount1.currency = "GBP";
+ await asyncElementRendered();
+ isnot(amount1.textContent, "", "Start with initial content");
+
+ info("setting an alphabetical invalid value");
+ amount1.value = "abcdef";
+ await asyncElementRendered();
+
+ is(amount1.getAttribute("value"), "abcdef", "Check @value");
+ is(amount1.value, "abcdef", "Check .value");
+ is(amount1.getAttribute("currency"), "GBP", "Check @currency");
+ is(amount1.currency, "GBP", "Check .currency");
+ is(amount1.textContent, "", "Invalid value should clear output");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_labelled_checkbox.html b/browser/components/payments/test/mochitest/test_labelled_checkbox.html
new file mode 100644
index 0000000000..2d6b9be98b
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_labelled_checkbox.html
@@ -0,0 +1,71 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the labelled-checkbox component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the labelled-checkbox component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <labelled-checkbox id="box0"></labelled-checkbox>
+ <labelled-checkbox id="box1" label="the label" value="the value"></labelled-checkbox>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the labelled-checkbox component **/
+
+import "../../res/components/labelled-checkbox.js";
+
+let box0 = document.getElementById("box0");
+let box1 = document.getElementById("box1");
+
+add_task(async function test_no_values() {
+ ok(box0, "box0 exists");
+ is(box0.label, null, "Initially un-labelled");
+ is(box0.value, null, "Check .value");
+ ok(!box0.checked, "Initially is not checked");
+ ok(!box0.querySelector("input:checked"), "has no checked inner input");
+
+ box0.checked = true;
+ box0.value = "New value";
+ box0.label = "New label";
+
+ await asyncElementRendered();
+
+ ok(box0.checked, "Becomes checked");
+ ok(box0.querySelector("input:checked"), "has a checked inner input");
+ is(box0.getAttribute("label"), "New label", "Assigned label");
+ is(box0.getAttribute("value"), "New value", "Assigned value");
+});
+
+add_task(async function test_initial_values() {
+ is(box1.label, "the label", "Initial label");
+ is(box1.value, "the value", "Initial value");
+ ok(!box1.checked, "Initially unchecked");
+ ok(!box1.querySelector("input:checked"), "has no checked inner input");
+
+ box1.checked = false;
+ box1.value = "New value";
+ box1.label = "New label";
+
+ await asyncElementRendered();
+
+ ok(!box1.checked, "Checked property remains falsey");
+ is(box1.getAttribute("value"), "New value", "Assigned value");
+ is(box1.getAttribute("label"), "New label", "Assigned label");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_order_details.html b/browser/components/payments/test/mochitest/test_order_details.html
new file mode 100644
index 0000000000..c97311d299
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_order_details.html
@@ -0,0 +1,215 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test the order-details component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the order-details component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/containers/order-details.css"/>
+
+ <template id="order-details-template">
+ <ul class="main-list"></ul>
+ <ul class="footer-items-list"></ul>
+
+ <div class="details-total">
+ <h2 class="label">Total</h2>
+ <currency-amount></currency-amount>
+ </div>
+ </template>
+</head>
+<body>
+ <p id="display">
+ <order-details></order-details>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the order-details component **/
+
+import OrderDetails from "../../res/containers/order-details.js";
+import {requestStore} from "../../res/mixins/PaymentStateSubscriberMixin.js";
+
+let orderDetails = document.querySelector("order-details");
+let emptyState = requestStore.getState();
+
+function setup() {
+ let initialState = deepClone(emptyState);
+ let cardGUID = "john-doe";
+ let johnDoeCard = deepClone(PTU.BasicCards.JohnDoe);
+ johnDoeCard.methodName = "basic-card";
+ johnDoeCard.guid = cardGUID;
+ let savedBasicCards = {
+ [cardGUID]: johnDoeCard,
+ };
+ initialState.selectedPaymentCard = cardGUID;
+ requestStore.setState(Object.assign(initialState, {savedBasicCards}));
+}
+
+add_task(async function isFooterItem() {
+ ok(OrderDetails.isFooterItem({
+ label: "Levy",
+ type: "tax",
+ amount: { currency: "USD", value: "1" },
+ }, "items with type of 'tax' are footer items"));
+ ok(!OrderDetails.isFooterItem({
+ label: "Levis",
+ amount: { currency: "USD", value: "1" },
+ }, "items without type of 'tax' aren't footer items"));
+});
+
+add_task(async function test_initial_state() {
+ setup();
+ is(orderDetails.mainItemsList.childElementCount, 0, "main items list is initially empty");
+ is(orderDetails.footerItemsList.childElementCount, 0, "footer items list is initially empty");
+ is(orderDetails.totalAmountElem.value, "0", "total amount is 0");
+});
+
+add_task(async function test_list_population() {
+ setup();
+ let state = requestStore.getState();
+ let request = state.request;
+ let paymentDetails = deepClone(request.paymentDetails);
+ paymentDetails.displayItems = [
+ {
+ label: "One",
+ amount: { currency: "USD", value: "5" },
+ },
+ {
+ label: "Two",
+ amount: { currency: "USD", value: "6" },
+ },
+ {
+ label: "Three",
+ amount: { currency: "USD", value: "7" },
+ },
+ ];
+
+ requestStore.setState({
+ request: Object.assign(deepClone(request), { paymentDetails }),
+ });
+
+ await asyncElementRendered();
+ is(orderDetails.mainItemsList.childElementCount, 3, "main items list has correct # children");
+ is(orderDetails.footerItemsList.childElementCount, 0, "footer items list has 0 children");
+
+ paymentDetails.displayItems = [
+ {
+ label: "Levy",
+ type: "tax",
+ amount: { currency: "USD", value: "1" },
+ },
+ {
+ label: "Item",
+ amount: { currency: "USD", value: "6" },
+ },
+ {
+ label: "Thing",
+ amount: { currency: "USD", value: "7" },
+ },
+ ];
+ Object.assign(request, { paymentDetails });
+ requestStore.setState({ request });
+ await asyncElementRendered();
+
+ is(orderDetails.mainItemsList.childElementCount, 2, "main list has correct # children");
+ is(orderDetails.footerItemsList.childElementCount, 1, "footer list has correct # children");
+});
+
+add_task(async function test_additionalDisplayItems() {
+ setup();
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails, {
+ modifiers: [{
+ additionalDisplayItems: [
+ {
+ label: "Card fee",
+ amount: { currency: "USD", value: "1.50" },
+ },
+ ],
+ supportedMethods: "basic-card",
+ total: {
+ label: "Total due",
+ amount: { currency: "USD", value: "3.50" },
+ },
+ }],
+ });
+ requestStore.setState({ request });
+ await asyncElementRendered();
+
+ is(orderDetails.mainItemsList.childElementCount, 0,
+ "main list added 0 children from additionalDisplayItems");
+ is(orderDetails.footerItemsList.childElementCount, 1,
+ "footer list added children from additionalDisplayItems");
+});
+
+
+add_task(async function test_total() {
+ setup();
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails, {
+ totalItem: { label: "foo", amount: { currency: "JPY", value: "5" }},
+ });
+ requestStore.setState({ request });
+ await asyncElementRendered();
+
+ is(orderDetails.totalAmountElem.value, "5", "total amount gets updated");
+ is(orderDetails.totalAmountElem.currency, "JPY", "total currency gets updated");
+});
+
+add_task(async function test_modified_total() {
+ setup();
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails, {
+ totalItem: { label: "foo", amount: { currency: "JPY", value: "5" }},
+ modifiers: [{
+ supportedMethods: "basic-card",
+ total: {
+ label: "Total due",
+ amount: { currency: "USD", value: "3.5" },
+ },
+ }],
+ });
+ requestStore.setState({request});
+ await asyncElementRendered();
+
+ is(orderDetails.totalAmountElem.value, "3.5", "total amount uses modifier total");
+ is(orderDetails.totalAmountElem.currency, "USD", "total currency uses modifier currency");
+});
+
+// The modifier is not applied since the cc network is not supported.
+add_task(async function test_non_supported_network() {
+ setup();
+ let request = Object.assign({}, requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails, {
+ totalItem: { label: "foo", amount: { currency: "JPY", value: "5" }},
+ modifiers: [{
+ supportedMethods: "basic-card",
+ total: {
+ label: "Total due",
+ amount: { currency: "USD", value: "3.5" },
+ },
+ data: {
+ supportedNetworks: ["mastercard"],
+ },
+ }],
+ });
+ requestStore.setState({request});
+ await asyncElementRendered();
+
+ is(orderDetails.totalAmountElem.value, "5", "total amount uses modifier total");
+ is(orderDetails.totalAmountElem.currency, "JPY", "total currency uses modifier currency");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_payer_address_picker.html b/browser/components/payments/test/mochitest/test_payer_address_picker.html
new file mode 100644
index 0000000000..df14857e69
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_payer_address_picker.html
@@ -0,0 +1,323 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the paymentOptions address-picker
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the paymentOptions address-picker</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display" style="height: 100vh; margin: 0;">
+ <iframe id="templateFrame" src="../../res/paymentRequest.xhtml" width="0" height="0"
+ sandbox="allow-same-origin"
+ style="float: left;"></iframe>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the payer requested details functionality **/
+
+import PaymentDialog from "../../res/containers/payment-dialog.js";
+
+function isVisible(elem) {
+ let result = elem.getBoundingClientRect().height > 0;
+ return result;
+}
+
+function setPaymentOptions(requestStore, options) {
+ let {request} = requestStore.getState();
+ request = Object.assign({}, request, {
+ paymentOptions: options,
+ });
+ return requestStore.setState({ request });
+}
+
+const SAVED_ADDRESSES = {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ "country": "US",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ "tel": "+1 519 555-5555",
+ "email": "foo@example.com",
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ "email": "bar@example.com",
+ },
+};
+
+let DUPED_ADDRESSES = {
+ "a9e830667189": {
+ "street-address": "Unit 1\n1505 Northeast Kentucky Industrial Parkway \n",
+ "address-level2": "Greenup",
+ "address-level1": "KY",
+ "postal-code": "41144",
+ "country": "US",
+ "email": "bob@example.com",
+ "guid": "a9e830667189",
+ "name": "Bob Smith",
+ },
+ "72a15aed206d": {
+ "street-address": "1 New St",
+ "address-level2": "York",
+ "address-level1": "SC",
+ "postal-code": "29745",
+ "country": "US",
+ "guid": "72a15aed206d",
+ "email": "mary@example.com",
+ "name": "Mary Sue",
+ },
+ "2b4dce0fbc1f": {
+ "street-address": "123 Park St",
+ "address-level2": "Springfield",
+ "address-level1": "OR",
+ "postal-code": "97403",
+ "country": "US",
+ "email": "rita@foo.com",
+ "guid": "2b4dce0fbc1f",
+ "name": "Rita Foo",
+ },
+ "46b2635a5b26": {
+ "street-address": "432 Another St",
+ "address-level2": "Springfield",
+ "address-level1": "OR",
+ "postal-code": "97402",
+ "country": "US",
+ "guid": "46b2635a5b26",
+ "name": "Rita Foo",
+ "tel": "+19871234567",
+ },
+};
+
+let elPicker;
+let elDialog;
+let initialState;
+
+add_task(async function setup_once() {
+ registerConsoleFilter(function consoleFilter(msg) {
+ return msg.errorMessage &&
+ msg.errorMessage.toString().includes("selectedPayerAddress option a9e830667189 " +
+ "does not exist");
+ });
+
+ let templateFrame = document.getElementById("templateFrame");
+ await SimpleTest.promiseFocus(templateFrame.contentWindow);
+
+ let displayEl = document.getElementById("display");
+ importDialogDependencies(templateFrame, displayEl);
+
+ elDialog = new PaymentDialog();
+ displayEl.appendChild(elDialog);
+ elPicker = elDialog.querySelector("address-picker.payer-related");
+
+ let {request} = elDialog.requestStore.getState();
+ initialState = Object.assign({}, {
+ changesPrevented: false,
+ request: Object.assign({}, request, { completeStatus: "" }),
+ orderDetailsShowing: false,
+ });
+});
+
+async function setup() {
+ // reset the store back to a known, default state
+ elDialog.requestStore.setState(deepClone(initialState));
+ await asyncElementRendered();
+}
+
+add_task(async function test_empty() {
+ await setup();
+
+ let {request, savedAddresses} = elPicker.requestStore.getState();
+ ok(!savedAddresses || !savedAddresses.length,
+ "Check initial state has no saved addresses");
+
+ let {paymentOptions} = request;
+ let payerRequested = paymentOptions.requestPayerName ||
+ paymentOptions.requestPayerEmail ||
+ paymentOptions.requestPayerPhone;
+ ok(!payerRequested, "Check initial state has no payer details requested");
+ ok(elPicker, "Check elPicker exists");
+ is(elPicker.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
+ is(isVisible(elPicker), false, "The address-picker is not visible");
+});
+
+// paymentOptions properties are acurately reflected in the address-fields attribute
+add_task(async function test_visible_fields() {
+ await setup();
+ let requestStore = elPicker.requestStore;
+ setPaymentOptions(requestStore, {
+ requestPayerName: true,
+ requestPayerEmail: true,
+ requestPayerPhone: true,
+ });
+
+ requestStore.setState({
+ savedAddresses: SAVED_ADDRESSES,
+ selectedPayerAddress: "48bnds6854t",
+ });
+
+ await asyncElementRendered();
+
+ let closedRichOption = elPicker.dropdown.querySelector(".rich-select-selected-option");
+ is(elPicker.dropdown.popupBox.children.length, 2, "Check dropdown has 2 addresses");
+ is(closedRichOption.getAttribute("guid"), "48bnds6854t", "expected option is visible");
+
+ for (let fieldName of ["name", "email", "tel"]) {
+ let elem = closedRichOption.querySelector(`.${fieldName}`);
+ ok(elem, `field ${fieldName} exists`);
+ ok(isVisible(elem), `field ${fieldName} is visible`);
+ }
+ ok(!closedRichOption.querySelector(".street-address"), "street-address element is not present");
+});
+
+add_task(async function test_selective_fields() {
+ await setup();
+ let requestStore = elPicker.requestStore;
+
+ requestStore.setState({
+ savedAddresses: SAVED_ADDRESSES,
+ selectedPayerAddress: "48bnds6854t",
+ });
+
+ let payerFieldVariations = [
+ {requestPayerName: true, requestPayerEmail: true, requestPayerPhone: true },
+ {requestPayerName: true, requestPayerEmail: false, requestPayerPhone: false },
+ {requestPayerName: false, requestPayerEmail: true, requestPayerPhone: false },
+ {requestPayerName: false, requestPayerEmail: false, requestPayerPhone: true },
+ {requestPayerName: true, requestPayerEmail: true, requestPayerPhone: false },
+ {requestPayerName: false, requestPayerEmail: true, requestPayerPhone: true },
+ {requestPayerName: true, requestPayerEmail: false, requestPayerPhone: true },
+ ];
+
+ for (let payerFields of payerFieldVariations) {
+ setPaymentOptions(requestStore, payerFields);
+ await asyncElementRendered();
+
+ let closedRichOption = elPicker.dropdown.querySelector(".rich-select-selected-option");
+ let elName = closedRichOption.querySelector(".name");
+ let elEmail = closedRichOption.querySelector(".email");
+ let elPhone = closedRichOption.querySelector(".tel");
+
+ is(!!elName && isVisible(elName), payerFields.requestPayerName,
+ "name field is correctly toggled");
+ is(!!elEmail && isVisible(elEmail), payerFields.requestPayerEmail,
+ "email field is correctly toggled");
+ is(!!elPhone && isVisible(elPhone), payerFields.requestPayerPhone,
+ "tel field is correctly toggled");
+
+ let numPayerFieldsRequested = [...Object.values(payerFields)].filter(val => val).length;
+ is(elPicker.getAttribute("break-after-nth-field"), numPayerFieldsRequested == 3 ? "1" : null,
+ "Check @break-after-nth-field");
+ if (numPayerFieldsRequested == 3) {
+ is(closedRichOption.breakAfterNthField, "1",
+ "Make sure @break-after-nth-field was propagated to <address-option>");
+ } else {
+ is(closedRichOption.breakAfterNthField, null, "Make sure @break-after-nth-field was cleared");
+ }
+ }
+});
+
+add_task(async function test_filtered_options() {
+ await setup();
+ let requestStore = elPicker.requestStore;
+ setPaymentOptions(requestStore, {
+ requestPayerName: true,
+ requestPayerEmail: true,
+ });
+
+ requestStore.setState({
+ savedAddresses: DUPED_ADDRESSES,
+ selectedPayerAddress: "a9e830667189",
+ });
+
+ await asyncElementRendered();
+
+ let closedRichOption = elPicker.dropdown.querySelector(".rich-select-selected-option");
+ is(elPicker.dropdown.popupBox.children.length, 4, "Check dropdown has 4 addresses");
+ is(closedRichOption.getAttribute("guid"), "a9e830667189", "expected option is visible");
+
+ for (let fieldName of ["name", "email"]) {
+ let elem = closedRichOption.querySelector(`.${fieldName}`);
+ ok(elem, `field ${fieldName} exists`);
+ ok(isVisible(elem), `field ${fieldName} is visible`);
+ }
+
+ // The selectedPayerAddress (a9e830667189) doesn't have a phone number and
+ // therefore will cause an error.
+ SimpleTest.expectUncaughtException(true);
+
+ setPaymentOptions(requestStore, {
+ requestPayerPhone: true,
+ });
+ await asyncElementRendered();
+
+ is(elPicker.dropdown.popupBox.children.length, 1, "Check dropdown has 1 addresses");
+
+ setPaymentOptions(requestStore, {});
+ await asyncElementRendered();
+
+ is(elPicker.dropdown.popupBox.children.length, 4, "Check dropdown has 4 addresses");
+});
+
+add_task(async function test_no_matches() {
+ await setup();
+ let requestStore = elPicker.requestStore;
+ setPaymentOptions(requestStore, {
+ requestPayerPhone: true,
+ });
+
+ // The selectedPayerAddress (a9e830667189) doesn't have a phone number and
+ // therefore will cause an error.
+ SimpleTest.expectUncaughtException(true);
+
+ requestStore.setState({
+ savedAddresses: {
+ "2b4dce0fbc1f": {
+ "email": "rita@foo.com",
+ "guid": "2b4dce0fbc1f",
+ "name": "Rita Foo",
+ },
+ "46b2635a5b26": {
+ "guid": "46b2635a5b26",
+ "name": "Rita Foo",
+ },
+ },
+ selectedPayerAddress: "a9e830667189",
+ });
+
+ await asyncElementRendered();
+
+ let closedRichOption = elPicker.dropdown.querySelector(".rich-select-selected-option");
+ is(elPicker.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
+ ok(closedRichOption.localName !== "address-option", "No option is selected and visible");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_payment_details_item.html b/browser/components/payments/test/mochitest/test_payment_details_item.html
new file mode 100644
index 0000000000..1a2cf302a4
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_payment_details_item.html
@@ -0,0 +1,65 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the payment-details-item component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the payment-details-item component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <payment-details-item id="item1"></payment-details-item>
+ <payment-details-item id="item2" label="Some item" amount-value="2" amount-currency="USD"></payment-details-item>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the payment-details-item component **/
+
+import "../../res/components/payment-details-item.js";
+
+let item1 = document.getElementById("item1");
+let item2 = document.getElementById("item2");
+
+add_task(async function test_no_value() {
+ ok(item1, "item1 exists");
+ is(item1.textContent, "", "Initially empty");
+
+ item1.label = "New label";
+ await asyncElementRendered();
+ is(item1.getAttribute("label"), "New label", "Check @label");
+ ok(!item1.hasAttribute("amount-value"), "Check @amount-value");
+ ok(!item1.hasAttribute("amount-currency"), "Check @amount-currency");
+ is(item1.label, "New label", "Check .label");
+ is(item1.amountValue, null, "Check .amountValue");
+ is(item1.amountCurrency, null, "Check .amountCurrency");
+
+ item1.label = null;
+ await asyncElementRendered();
+ ok(!item1.hasAttribute("label"), "Setting to null should remove @label");
+ is(item1.textContent, "", "Becomes empty when label is removed");
+});
+
+add_task(async function test_initial_attribute_values() {
+ is(item2.label, "Some item", "Check .label");
+ is(item2.amountValue, "2", "Check .amountValue");
+ is(item2.amountCurrency, "USD", "Check .amountCurrency");
+});
+
+add_task(async function test_templating() {
+ ok(item2.querySelector("currency-amount"), "creates currency-amount component");
+ ok(item2.querySelector(".label"), "creates label");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_payment_dialog.html b/browser/components/payments/test/mochitest/test_payment_dialog.html
new file mode 100644
index 0000000000..200c91ec08
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_payment_dialog.html
@@ -0,0 +1,360 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the payment-dialog custom element
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the payment-dialog element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="sinon-7.2.7.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
+</head>
+<body>
+ <p id="display" style="height: 100vh; margin: 0;">
+ <iframe id="templateFrame" src="paymentRequest.xhtml" width="0" height="0"
+ sandbox="allow-same-origin"
+ style="float: left;"></iframe>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the payment-dialog element **/
+
+/* global sinon */
+
+import PaymentDialog from "../../res/containers/payment-dialog.js";
+
+let el1;
+
+add_task(async function setup_once() {
+ let templateFrame = document.getElementById("templateFrame");
+ await SimpleTest.promiseFocus(templateFrame.contentWindow);
+ let displayEl = document.getElementById("display");
+ importDialogDependencies(templateFrame, displayEl);
+
+ el1 = new PaymentDialog();
+ displayEl.appendChild(el1);
+
+ sinon.spy(el1, "render");
+ sinon.spy(el1, "stateChangeCallback");
+});
+
+async function setup() {
+ let {request} = el1.requestStore.getState();
+ await el1.requestStore.setState({
+ changesPrevented: false,
+ request: Object.assign({}, request, {completeStatus: ""}),
+ orderDetailsShowing: false,
+ page: {
+ id: "payment-summary",
+ },
+ });
+
+ el1.render.reset();
+ el1.stateChangeCallback.reset();
+}
+
+add_task(async function test_initialState() {
+ await setup();
+ let initialState = el1.requestStore.getState();
+ let elDetails = el1._orderDetailsOverlay;
+
+ is(initialState.orderDetailsShowing, false, "orderDetailsShowing is initially false");
+ ok(elDetails.hasAttribute("hidden"), "Check details are hidden");
+ is(initialState.page.id, "payment-summary", "Check initial page");
+});
+
+add_task(async function test_viewAllButtonVisibility() {
+ await setup();
+
+ let button = el1._viewAllButton;
+ ok(button.hidden, "Button is initially hidden when there are no items to show");
+ ok(isHidden(button), "Button should be visibly hidden since bug 1469464");
+
+ // Add a display item.
+ let request = deepClone(el1.requestStore.getState().request);
+ request.paymentDetails.displayItems = [
+ {
+ "label": "Triangle",
+ "amount": {
+ "currency": "CAD",
+ "value": "3",
+ },
+ },
+ ];
+ await el1.requestStore.setState({ request });
+ await asyncElementRendered();
+
+ // Check if the "View all items" button is visible.
+ ok(!button.hidden, "Button is visible");
+});
+
+add_task(async function test_viewAllButton() {
+ await setup();
+
+ let elDetails = el1._orderDetailsOverlay;
+ let button = el1._viewAllButton;
+
+ button.click();
+ await asyncElementRendered();
+
+ ok(el1.stateChangeCallback.calledOnce, "stateChangeCallback called once");
+ ok(el1.render.calledOnce, "render called once");
+
+ let state = el1.requestStore.getState();
+ is(state.orderDetailsShowing, true, "orderDetailsShowing becomes true");
+ ok(!elDetails.hasAttribute("hidden"), "Check details aren't hidden");
+});
+
+add_task(async function test_changesPrevented() {
+ await setup();
+ let state = el1.requestStore.getState();
+ is(state.changesPrevented, false, "changesPrevented is initially false");
+ let disabledOverlay = document.getElementById("disabled-overlay");
+ ok(disabledOverlay.hidden, "Overlay should initially be hidden");
+ await el1.requestStore.setState({changesPrevented: true});
+ await asyncElementRendered();
+ ok(!disabledOverlay.hidden, "Overlay should prevent changes");
+});
+
+add_task(async function test_initial_completeStatus() {
+ await setup();
+ let {request, page} = el1.requestStore.getState();
+ is(request.completeStatus, "", "completeStatus is initially empty");
+
+ let payButton = document.getElementById("pay");
+ is(payButton, document.querySelector(`#${page.id} button.primary`),
+ "Primary button is the pay button in the initial state");
+ is(payButton.textContent, "Pay", "Check default label");
+ ok(payButton.disabled, "Button is disabled by default");
+});
+
+add_task(async function test_generic_errors() {
+ await setup();
+ const SHIPPING_GENERIC_ERROR = "Can't ship to that address";
+ el1._errorText.dataset.shippingGenericError = SHIPPING_GENERIC_ERROR;
+ el1.requestStore.setState({
+ savedAddresses: {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ "country": "US",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ "tel": "+1 519 555-5555",
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ },
+ },
+ selectedShippingAddress: "48bnds6854t",
+ });
+ await asyncElementRendered();
+
+ let picker = el1._shippingAddressPicker;
+ ok(picker.selectedOption, "Address picker should have a selected option");
+ is(el1._errorText.textContent, SHIPPING_GENERIC_ERROR,
+ "Generic error message should be shown when no shipping options or error are provided");
+});
+
+add_task(async function test_processing_completeStatus() {
+ // "processing": has overlay. Check button visibility
+ await setup();
+ let {request} = el1.requestStore.getState();
+ // this a transition state, set when waiting for a response from the merchant page
+ el1.requestStore.setState({
+ changesPrevented: true,
+ request: Object.assign({}, request, {completeStatus: "processing"}),
+ });
+ await asyncElementRendered();
+
+ let primaryButtons = document.querySelectorAll("footer button.primary");
+ ok(Array.from(primaryButtons).every(el => isHidden(el) || el.disabled),
+ "all primary footer buttons are hidden or disabled");
+
+ info("Got an update from the parent process with an error from .retry()");
+ request = el1.requestStore.getState().request;
+ let paymentDetails = deepClone(request.paymentDetails);
+ paymentDetails.error = "Sample retry error";
+ await el1.setStateFromParent({
+ request: Object.assign({}, request, {
+ completeStatus: "",
+ paymentDetails,
+ }),
+ });
+ await asyncElementRendered();
+
+ let {changesPrevented, page} = el1.requestStore.getState();
+ ok(!changesPrevented, "Changes should no longer be prevented");
+ is(page.id, "payment-summary", "Check back on payment-summary");
+ ok(el1.innerText.includes("Sample retry error"), "Check error text is visible");
+});
+
+add_task(async function test_success_unknown_completeStatus() {
+ // in the "success" and "unknown" completion states the dialog would normally be closed
+ // so just ensure it is left in a good state
+ for (let completeStatus of ["success", "unknown"]) {
+ await setup();
+ let {request} = el1.requestStore.getState();
+ el1.requestStore.setState({
+ request: Object.assign({}, request, {completeStatus}),
+ });
+ await asyncElementRendered();
+
+ let {page} = el1.requestStore.getState();
+
+ // this status doesnt change page
+ let payButton = document.getElementById("pay");
+ is(payButton, document.querySelector(`#${page.id} button.primary`),
+ `Primary button is the pay button in the ${completeStatus} state`);
+
+ if (completeStatus == "success") {
+ is(payButton.textContent, "Done", "Check button label");
+ }
+ if (completeStatus == "unknown") {
+ is(payButton.textContent, "Unknown", "Check button label");
+ }
+ ok(payButton.disabled, "Button is disabled by default");
+ }
+});
+
+add_task(async function test_timeout_fail_completeStatus() {
+ // in these states the dialog stays open and presents a single
+ // button for acknowledgement
+ for (let completeStatus of ["fail", "timeout"]) {
+ await setup();
+ let {request} = el1.requestStore.getState();
+ el1.requestStore.setState({
+ request: Object.assign({}, request, {completeStatus}),
+ page: {
+ id: `completion-${completeStatus}-error`,
+ },
+ });
+ await asyncElementRendered();
+
+ let {page} = el1.requestStore.getState();
+ let pageElem = document.querySelector(`#${page.id}`);
+ let payButton = document.getElementById("pay");
+ let primaryButton = pageElem.querySelector("button.primary");
+
+ ok(pageElem && !isHidden(pageElem, `page element for ${page.id} exists and is visible`));
+ ok(!isHidden(primaryButton), "Primary button is visible");
+ ok(payButton != primaryButton,
+ `Primary button is the not pay button in the ${completeStatus} state`);
+ ok(isHidden(payButton), "Pay button is not visible");
+ is(primaryButton.textContent, "Close", "Check button label");
+
+ let rect = primaryButton.getBoundingClientRect();
+ let visibleElement =
+ document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
+ ok(primaryButton === visibleElement, "Primary button is on top of the overlay");
+ }
+});
+
+add_task(async function test_scrollPaymentRequestPage() {
+ await setup();
+ info("making the payment-dialog container small to require scrolling");
+ el1.parentElement.style.height = "100px";
+ let summaryPageBody = document.querySelector("#payment-summary .page-body");
+ is(summaryPageBody.scrollTop, 0, "Page body not scrolled initially");
+ let securityCodeInput = summaryPageBody.querySelector("payment-method-picker input");
+ securityCodeInput.focus();
+ await new Promise(resolve => SimpleTest.executeSoon(resolve));
+ ok(summaryPageBody.scrollTop > 0, "Page body scrolled after focusing the CVV field");
+ el1.parentElement.style.height = "";
+});
+
+add_task(async function test_acceptedCards() {
+ let initialState = el1.requestStore.getState();
+ let paymentMethods = [{
+ supportedMethods: "basic-card",
+ data: {
+ supportedNetworks: ["visa", "mastercard"],
+ },
+ }];
+ el1.requestStore.setState({
+ request: Object.assign({}, initialState.request, {
+ paymentMethods,
+ }),
+ });
+ await asyncElementRendered();
+
+ let acceptedCards = el1._acceptedCardsList;
+ ok(acceptedCards && !isHidden(acceptedCards), "Accepted cards list is present and visible");
+
+ paymentMethods = [{
+ supportedMethods: "basic-card",
+ }];
+ el1.requestStore.setState({
+ request: Object.assign({}, initialState.request, {
+ paymentMethods,
+ }),
+ });
+ await asyncElementRendered();
+
+ acceptedCards = el1._acceptedCardsList;
+ ok(acceptedCards && isHidden(acceptedCards), "Accepted cards list is present but hidden");
+});
+
+add_task(async function test_picker_labels() {
+ await setup();
+ let picker = el1._shippingOptionPicker;
+
+ const SHIPPING_OPTIONS_LABEL = "Shipping options";
+ const DELIVERY_OPTIONS_LABEL = "Delivery options";
+ const PICKUP_OPTIONS_LABEL = "Pickup options";
+ picker.dataset.shippingOptionsLabel = SHIPPING_OPTIONS_LABEL;
+ picker.dataset.deliveryOptionsLabel = DELIVERY_OPTIONS_LABEL;
+ picker.dataset.pickupOptionsLabel = PICKUP_OPTIONS_LABEL;
+
+ for (let [shippingType, label] of [
+ ["shipping", SHIPPING_OPTIONS_LABEL],
+ ["delivery", DELIVERY_OPTIONS_LABEL],
+ ["pickup", PICKUP_OPTIONS_LABEL],
+ ]) {
+ let request = deepClone(el1.requestStore.getState().request);
+ request.paymentOptions.requestShipping = true;
+ request.paymentOptions.shippingType = shippingType;
+ await el1.requestStore.setState({ request });
+ await asyncElementRendered();
+ is(picker.labelElement.textContent, label,
+ `Label should be appropriate for ${shippingType}`);
+ }
+});
+
+add_task(async function test_disconnect() {
+ await setup();
+
+ el1.remove();
+ await el1.requestStore.setState({orderDetailsShowing: true});
+ await asyncElementRendered();
+ ok(el1.stateChangeCallback.notCalled, "stateChangeCallback not called");
+ ok(el1.render.notCalled, "render not called");
+
+ let elDetails = el1._orderDetailsOverlay;
+ ok(elDetails.hasAttribute("hidden"), "details overlay remains hidden");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html b/browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html
new file mode 100644
index 0000000000..88f7080f08
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html
@@ -0,0 +1,252 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the payment-dialog custom element
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the payment-dialog element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
+</head>
+<body>
+ <p id="display" style="height: 100vh; margin: 0;">
+ <iframe id="templateFrame" src="paymentRequest.xhtml" width="0" height="0"
+ sandbox="allow-same-origin"
+ style="float: left;"></iframe>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the payment-dialog element **/
+
+import PaymentDialog from "../../res/containers/payment-dialog.js";
+
+let el1;
+
+add_task(async function setupOnce() {
+ let templateFrame = document.getElementById("templateFrame");
+ await SimpleTest.promiseFocus(templateFrame.contentWindow);
+
+ let displayEl = document.getElementById("display");
+ importDialogDependencies(templateFrame, displayEl);
+
+ el1 = new PaymentDialog();
+ displayEl.appendChild(el1);
+});
+
+async function setup({shippingRequired, payerRequired}) {
+ let state = deepClone(el1.requestStore.getState());
+ state.request.paymentDetails.shippingOptions = shippingRequired ? [{
+ id: "123",
+ label: "Carrier Pigeon",
+ amount: {
+ currency: "USD",
+ value: 10,
+ },
+ selected: false,
+ }, {
+ id: "456",
+ label: "Lightspeed (default)",
+ amount: {
+ currency: "USD",
+ value: 20,
+ },
+ selected: true,
+ }] : null;
+ state.request.paymentOptions.requestShipping = shippingRequired;
+ state.request.paymentOptions.requestPayerName = payerRequired;
+ state.request.paymentOptions.requestPayerPhone = payerRequired;
+ state.savedAddresses = shippingRequired || payerRequired ? {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ "country": "US",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ "tel": "+1 519 555-5555",
+ },
+ "68gjdh354j": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "68gjdh354j",
+ "name": "Mrs. Bar",
+ "postal-code": "94041",
+ "street-address": "P.O. Box 123",
+ "tel": "+1 650 555-5555",
+ },
+ "abcdef1234": {
+ "address-level1": "CA",
+ "address-level2": "Mountain View",
+ "country": "US",
+ "guid": "abcdef1234",
+ "name": "Jane Fields",
+ },
+ } : {};
+ state.savedBasicCards = {
+ "john-doe": Object.assign({
+ "cc-exp": (new Date()).getFullYear() + 9 + "-01",
+ methodName: "basic-card",
+ guid: "aaa1",
+ }, deepClone(PTU.BasicCards.JohnDoe)),
+ "missing-fields": Object.assign({
+ methodName: "basic-card",
+ guid: "aaa2",
+ }, deepClone(PTU.BasicCards.MissingFields)),
+ };
+ state.selectedPayerAddress = null;
+ state.selectedPaymentCard = null;
+ state.selectedShippingAddress = null;
+ state.selectedShippingOption = null;
+ await el1.requestStore.setState(state);
+
+ // Fill the security code input so it doesn't interfere with checking the pay
+ // button state for dropdown changes.
+ el1._paymentMethodPicker.securityCodeInput.querySelector("input").select();
+ sendString("123");
+ await asyncElementRendered();
+}
+
+function selectFirstItemOfPicker(picker) {
+ picker.dropdown.popupBox.focus();
+ let options = picker.dropdown.popupBox.children;
+ if (options[0].selected) {
+ ok(false, `"${options[0].textContent}" was already selected`);
+ return;
+ }
+ info(`Selecting "${options[0].textContent}" from the options`);
+
+ synthesizeKey(options[0].textContent.trim().split(/\s+/)[0], {});
+ ok(picker.dropdown.selectedOption, `Option should be selected for ${picker.localName}`);
+}
+
+function selectLastItemOfPicker(picker) {
+ picker.dropdown.popupBox.focus();
+ let options = picker.dropdown.popupBox.children;
+ let lastOption = options[options.length - 1];
+ if (lastOption.selected) {
+ ok(false, `"${lastOption.textContent}" was already selected`);
+ return;
+ }
+
+ synthesizeKey(lastOption.textContent.trim().split(/\s+/)[0], {});
+ ok(picker.dropdown.selectedOption, `Option should be selected for ${picker.localName}`);
+}
+
+add_task(async function runTests() {
+ let allPickers = {
+ shippingAddress: el1._shippingAddressPicker,
+ shippingOption: el1._shippingOptionPicker,
+ paymentMethod: el1._paymentMethodPicker,
+ payerAddress: el1._payerAddressPicker,
+ };
+ let testCases = [
+ {
+ label: "shippingAndPayerRequired",
+ setup: { shippingRequired: true, payerRequired: true },
+ pickers: Object.values(allPickers),
+ }, {
+ label: "payerRequired",
+ setup: { payerRequired: true },
+ pickers: [allPickers.paymentMethod, allPickers.payerAddress],
+ }, {
+ label: "shippingRequired",
+ setup: { shippingRequired: true },
+ pickers: [
+ allPickers.shippingAddress,
+ allPickers.shippingOption,
+ allPickers.paymentMethod,
+ ],
+ },
+ ];
+
+ for (let testCase of testCases) {
+ info(`Starting testcase ${testCase.label}`);
+ await setup(testCase.setup);
+
+ for (let picker of testCase.pickers) {
+ ok(!picker.dropdown.selectedOption, `No option selected for ${picker.localName}`);
+ }
+ let hiddenPickers = Object.values(allPickers).filter(p => !testCase.pickers.includes(p));
+ for (let hiddenPicker of hiddenPickers) {
+ ok(hiddenPicker.hidden, `${hiddenPicker.localName} should be hidden`);
+ }
+
+ let payButton = document.getElementById("pay");
+ ok(payButton.disabled, "Button is disabled when required options are not selected");
+
+ let stateChangedPromise = promiseStateChange(el1.requestStore);
+ testCase.pickers.forEach(selectFirstItemOfPicker);
+ await stateChangedPromise;
+
+ ok(!payButton.disabled, "Button is enabled when required options are selected");
+
+ // Individually toggle each picker to see how the missing fields affects Pay button.
+ for (let picker of testCase.pickers) {
+ // There is no "invalid" option for shipping options.
+ if (picker == allPickers.shippingOption) {
+ continue;
+ }
+ info(`picker: ${picker.localName} with className: ${picker.className}`);
+
+ // Setup the invalid state
+ stateChangedPromise = promiseStateChange(el1.requestStore);
+ selectLastItemOfPicker(picker);
+ await stateChangedPromise;
+
+ ok(payButton.disabled, "Button is disabled when selected option has missing fields");
+
+ // Now setup the valid state
+ stateChangedPromise = promiseStateChange(el1.requestStore);
+ selectFirstItemOfPicker(picker);
+ await stateChangedPromise;
+
+ ok(!payButton.disabled, "Button is enabled when selected option has all required fields");
+ }
+ }
+});
+
+add_task(async function test_securityCodeRequired() {
+ await setup({
+ payerRequired: false,
+ shippingRequired: false,
+ });
+
+ let picker = el1._paymentMethodPicker;
+ let payButton = document.getElementById("pay");
+
+ let stateChangedPromise = promiseStateChange(el1.requestStore);
+ selectFirstItemOfPicker(picker);
+ await stateChangedPromise;
+
+ picker.securityCodeInput.querySelector("input").select();
+ stateChangedPromise = promiseStateChange(el1.requestStore);
+ synthesizeKey("VK_DELETE");
+ await stateChangedPromise;
+
+ ok(payButton.disabled, "Button is disabled when CVV is empty");
+
+ picker.securityCodeInput.querySelector("input").select();
+ stateChangedPromise = promiseStateChange(el1.requestStore);
+ sendString("123");
+ await stateChangedPromise;
+
+ ok(!payButton.disabled, "Button is enabled when CVV is filled");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_payment_method_picker.html b/browser/components/payments/test/mochitest/test_payment_method_picker.html
new file mode 100644
index 0000000000..1e2adee856
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_payment_method_picker.html
@@ -0,0 +1,279 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the payment-method-picker component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the payment-method-picker component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/basic-card-option.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <payment-method-picker id="picker1"
+ data-invalid-label="picker1: Missing or invalid"
+ selected-state-key="selectedPaymentCard"></payment-method-picker>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the payment-method-picker component **/
+
+import "../../res/components/basic-card-option.js";
+import "../../res/containers/payment-method-picker.js";
+
+let picker1 = document.getElementById("picker1");
+
+add_task(async function test_empty() {
+ ok(picker1, "Check picker1 exists");
+ let {savedBasicCards} = picker1.requestStore.getState();
+ is(Object.keys(savedBasicCards).length, 0, "Check empty initial state");
+ is(picker1.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
+});
+
+add_task(async function test_initialSet() {
+ await picker1.requestStore.setState({
+ savedBasicCards: {
+ "48bnds6854t": {
+ "cc-exp": "2017-02",
+ "cc-exp-month": 2,
+ "cc-exp-year": 2017,
+ "cc-name": "John Doe",
+ "cc-number": "************9999",
+ "cc-type": "mastercard",
+ "guid": "48bnds6854t",
+ timeLastUsed: 300,
+ },
+ "68gjdh354j": {
+ "cc-exp": "2017-08",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2017,
+ "cc-name": "J Smith",
+ "cc-number": "***********1234",
+ "cc-type": "visa",
+ "guid": "68gjdh354j",
+ timeLastUsed: 100,
+ },
+ "123456789abc": {
+ "cc-name": "Jane Fields",
+ "cc-given-name": "Jane",
+ "cc-additional-name": "",
+ "cc-family-name": "Fields",
+ "cc-number": "************9876",
+ "guid": "123456789abc",
+ timeLastUsed: 200,
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 3, "Check dropdown has all three cards");
+ ok(options[0].textContent.includes("John Doe"), "Check first card based on timeLastUsed");
+ ok(options[1].textContent.includes("Jane Fields"), "Check second card based on timeLastUsed");
+ ok(options[2].textContent.includes("J Smith"), "Check third card based on timeLastUsed");
+});
+
+add_task(async function test_update() {
+ await picker1.requestStore.setState({
+ savedBasicCards: {
+ "48bnds6854t": {
+ // Same GUID, different values to trigger an update
+ "cc-exp": "2017-09",
+ "cc-exp-month": 9,
+ "cc-exp-year": 2017,
+ // cc-name was cleared which means it's not returned
+ "cc-number": "************9876",
+ "cc-type": "amex",
+ "guid": "48bnds6854t",
+ },
+ "68gjdh354j": {
+ "cc-exp": "2017-08",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2017,
+ "cc-name": "J Smith",
+ "cc-number": "***********1234",
+ "cc-type": "visa",
+ "guid": "68gjdh354j",
+ },
+ "123456789abc": {
+ "cc-name": "Jane Fields",
+ "cc-given-name": "Jane",
+ "cc-additional-name": "",
+ "cc-family-name": "Fields",
+ "cc-number": "************9876",
+ "guid": "123456789abc",
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 3, "Check dropdown still has three cards");
+ ok(!options[0].textContent.includes("John Doe"), "Check cleared first cc-name");
+ ok(options[0].textContent.includes("9876"), "Check updated first cc-number");
+ ok(options[0].textContent.includes("09"), "Check updated first exp-month");
+
+ ok(options[1].textContent.includes("J Smith"), "Check second card is the same");
+ ok(options[2].textContent.includes("Jane Fields"), "Check third card is the same");
+});
+
+add_task(async function test_change_selected_card() {
+ let options = picker1.dropdown.popupBox.children;
+ is(picker1.dropdown.selectedOption, null, "Should default to no selected option");
+ let {
+ selectedPaymentCard,
+ selectedPaymentCardSecurityCode,
+ } = picker1.requestStore.getState();
+ is(selectedPaymentCard, null, "store should have no option selected");
+ is(selectedPaymentCardSecurityCode, null, "store should have no security code");
+ ok(!picker1.classList.contains("invalid-selected-option"), "No validation on an empty selection");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+
+ await SimpleTest.promiseFocus();
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey("************9876", {});
+ await asyncElementRendered();
+ ok(true, "Focused the security code field");
+ ok(!picker1.open, "Picker should be closed");
+
+ let selectedOption = picker1.dropdown.selectedOption;
+ is(selectedOption, options[2], "Selected option should now be the third option");
+ selectedPaymentCard = picker1.requestStore.getState().selectedPaymentCard;
+ is(selectedPaymentCard, selectedOption.getAttribute("guid"),
+ "store should have third option selected");
+ selectedPaymentCardSecurityCode = picker1.requestStore.getState().selectedPaymentCardSecurityCode;
+ is(selectedPaymentCardSecurityCode, null, "store should have empty security code");
+ ok(picker1.classList.contains("invalid-selected-option"), "Missing fields for the third option");
+ ok(!isHidden(picker1.invalidLabel), "The invalid label should be visible");
+ is(picker1.invalidLabel.innerText, picker1.dataset.invalidLabel, "Check displayed error text");
+
+ await SimpleTest.promiseFocus();
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey("visa", {});
+ await asyncElementRendered();
+ ok(true, "Focused the security code field");
+ ok(!picker1.open, "Picker should be closed");
+
+ selectedOption = picker1.dropdown.selectedOption;
+ is(selectedOption, options[1], "Selected option should now be the second option");
+ selectedPaymentCard = picker1.requestStore.getState().selectedPaymentCard;
+ is(selectedPaymentCard, selectedOption.getAttribute("guid"),
+ "store should have second option selected");
+ selectedPaymentCardSecurityCode = picker1.requestStore.getState().selectedPaymentCardSecurityCode;
+ is(selectedPaymentCardSecurityCode, null, "store should have empty security code");
+ ok(!picker1.classList.contains("invalid-selected-option"), "The second option has all fields");
+ ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
+
+ let stateChangePromise = promiseStateChange(picker1.requestStore);
+
+ // Type in the security code field
+ picker1.securityCodeInput.querySelector("input").focus();
+ sendString("836");
+ sendKey("Tab");
+ let state = await stateChangePromise;
+ is(state.selectedPaymentCardSecurityCode, "836", "Check security code in state");
+});
+
+add_task(async function test_delete() {
+ await picker1.requestStore.setState({
+ savedBasicCards: {
+ // 48bnds6854t was deleted
+ "68gjdh354j": {
+ "cc-exp": "2017-08",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2017,
+ "cc-name": "J Smith",
+ "cc-number": "***********1234",
+ "cc-type": "visa",
+ "guid": "68gjdh354j",
+ },
+ "123456789abc": {
+ "cc-name": "Jane Fields",
+ "cc-given-name": "Jane",
+ "cc-additional-name": "",
+ "cc-family-name": "Fields",
+ "cc-number": "************9876",
+ "guid": "123456789abc",
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 2, "Check dropdown has two remaining cards");
+ ok(options[0].textContent.includes("J Smith"), "Check remaining card #1");
+ ok(options[1].textContent.includes("Jane Fields"), "Check remaining card #2");
+});
+
+add_task(async function test_supportedNetworks_tempCards() {
+ await picker1.requestStore.reset();
+
+ let request = Object.assign({}, picker1.requestStore.getState().request);
+ request.paymentMethods = [
+ {
+ supportedMethods: "basic-card",
+ data: {
+ supportedNetworks: [
+ "mastercard",
+ "visa",
+ ],
+ },
+ },
+ ];
+
+ await picker1.requestStore.setState({
+ request,
+ selectedPaymentCard: "68gjdh354j",
+ tempBasicCards: {
+ "68gjdh354j": {
+ "cc-exp": "2017-08",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2017,
+ "cc-name": "J Smith",
+ "cc-number": "***********1234",
+ "cc-type": "discover",
+ "guid": "68gjdh354j",
+ },
+ },
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 1, "Check dropdown has one card");
+ ok(options[0].textContent.includes("J Smith"), "Check remaining card #1");
+
+ ok(picker1.classList.contains("invalid-selected-option"),
+ "Check discover is recognized as not supported");
+ is(picker1.invalidLabel.innerText, picker1.dataset.invalidLabel, "Check displayed error text");
+
+ info("change the card to be a visa");
+ await picker1.requestStore.setState({
+ tempBasicCards: {
+ "68gjdh354j": {
+ "cc-exp": "2017-08",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2017,
+ "cc-name": "J Smith",
+ "cc-number": "***********1234",
+ "cc-type": "visa",
+ "guid": "68gjdh354j",
+ },
+ },
+ });
+ await asyncElementRendered();
+
+ ok(!picker1.classList.contains("invalid-selected-option"),
+ "Check visa is recognized as supported");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_rich_select.html b/browser/components/payments/test/mochitest/test_rich_select.html
new file mode 100644
index 0000000000..e071ed15e2
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_rich_select.html
@@ -0,0 +1,150 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the rich-select component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the rich-select component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+ <script src="../../res/unprivileged-fallbacks.js"></script>
+ <script src="autofillEditForms.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/basic-card-option.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the rich-select address-option component **/
+
+import AddressOption from "../../res/components/address-option.js";
+import RichSelect from "../../res/components/rich-select.js";
+
+let addresses = {
+ "58gjdh354k": {
+ "email": "emzembrano92@email.com",
+ "name": "Emily Zembrano",
+ "street-address": "717 Hyde Street #6",
+ "address-level2": "San Francisco",
+ "address-level1": "CA",
+ "tel": "415 203 0845",
+ "postal-code": "94109",
+ "country": "USA",
+ "guid": "58gjdh354k",
+ },
+ "67gjdh354k": {
+ "email": "jenz9382@email.com",
+ "name": "Jennifer Zembrano",
+ "street-address": "42 Fairydust Lane",
+ "address-level2": "Lala Land",
+ "address-level1": "HI",
+ "tel": "415 439 2827",
+ "postal-code": "98765",
+ "country": "USA",
+ "guid": "67gjdh354k",
+ },
+};
+
+let select1 = new RichSelect();
+for (let address of Object.values(addresses)) {
+ let option = document.createElement("option");
+ option.textContent = address.name + " " + address["street-address"];
+ option.setAttribute("value", address.guid);
+ option.dataset.fieldSeparator = ", ";
+ for (let field of Object.keys(address)) {
+ option.setAttribute(field, address[field]);
+ }
+ select1.popupBox.appendChild(option);
+}
+select1.setAttribute("option-type", "address-option");
+select1.value = "";
+document.getElementById("display").appendChild(select1);
+
+let options = select1.popupBox.children;
+let option1 = options[0];
+let option2 = options[1];
+
+function get_selected_clone() {
+ return select1.querySelector(".rich-select-selected-option");
+}
+
+function is_visible(element, message) {
+ ok(!isHidden(element), message);
+}
+
+add_task(async function test_clickable_area() {
+ ok(select1, "select1 exists");
+ isnot(document.activeElement, select1.popupBox, "<select> shouldn't have focus");
+ synthesizeMouseAtCenter(select1, {});
+ is(document.activeElement, select1.popupBox, "<select> should have focus when clicked");
+ document.activeElement.blur();
+});
+
+add_task(async function test_closed_state_on_selection() {
+ ok(select1, "select1 exists");
+ select1.popupBox.focus();
+ synthesizeKey(option1.textContent, {});
+ await asyncElementRendered();
+ ok(option1.selected, "option 1 is now selected");
+
+ let selectedClone = get_selected_clone();
+ is_visible(selectedClone, "The selected clone should be visible at all times");
+ is(selectedClone.getAttribute("email"), option1.getAttribute("email"),
+ "The selected clone email should be equivalent to the selected option 2");
+ is(selectedClone.getAttribute("name"), option1.getAttribute("name"),
+ "The selected clone name should be equivalent to the selected option 2");
+});
+
+add_task(async function test_multi_select_not_supported_in_dropdown() {
+ ok(option1.selected, "Option 1 should be selected from prior test");
+
+ select1.popupBox.focus();
+ synthesizeKey(option2.textContent, {});
+ await asyncElementRendered();
+
+ ok(!option1.selected, "Option 1 should no longer be selected after selecting option1");
+ ok(option2.selected, "Option 2 should now have selected property set to true");
+});
+
+add_task(async function test_selected_clone_should_equal_selected_option() {
+ ok(option2.selected, "option 2 should be selected");
+
+ let clonedOptions = select1.querySelectorAll(".rich-select-selected-option");
+ is(clonedOptions.length, 1, "there should only be one cloned option");
+
+ let clonedOption = clonedOptions[0];
+ for (let attrName of AddressOption.recordAttributes) {
+ is(clonedOption.attributes[attrName] && clonedOption.attributes[attrName].value,
+ option2.attributes[attrName] && option2.attributes[attrName].value,
+ "attributes should have matching value; name=" + attrName);
+ }
+
+ select1.popupBox.focus();
+ synthesizeKey(option1.textContent, {});
+ await asyncElementRendered();
+
+ clonedOptions = select1.querySelectorAll(".rich-select-selected-option");
+ is(clonedOptions.length, 1, "there should only be one cloned option");
+
+ clonedOption = clonedOptions[0];
+ for (let attrName of AddressOption.recordAttributes) {
+ is(clonedOption.attributes[attrName] && clonedOption.attributes[attrName].value,
+ option1.attributes[attrName] && option1.attributes[attrName].value,
+ "attributes should have matching value; name=" + attrName);
+ }
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/payments/test/mochitest/test_shipping_option_picker.html b/browser/components/payments/test/mochitest/test_shipping_option_picker.html
new file mode 100644
index 0000000000..23a075dd3a
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_shipping_option_picker.html
@@ -0,0 +1,180 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the shipping-option-picker component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the shipping-option-picker component</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="payments_common.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+ <link rel="stylesheet" type="text/css" href="../../res/components/shipping-option.css"/>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ <shipping-option-picker id="picker1"></shipping-option-picker>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the shipping-option-picker component **/
+
+import "../../res/containers/shipping-option-picker.js";
+
+let picker1 = document.getElementById("picker1");
+
+add_task(async function test_empty() {
+ ok(picker1, "Check picker1 exists");
+ let state = picker1.requestStore.getState();
+ let {shippingOptions} = state && state.request && state.request.paymentDetails;
+ is(Object.keys(shippingOptions).length, 0, "Check empty initial state");
+ is(picker1.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
+ ok(picker1.editLink.hidden, "Check that picker edit link is always hidden");
+ ok(picker1.addLink.hidden, "Check that picker add link is always hidden");
+});
+
+add_task(async function test_initialSet() {
+ picker1.requestStore.setState({
+ request: {
+ paymentDetails: {
+ shippingOptions: [
+ {
+ id: "123",
+ label: "Carrier Pigeon",
+ amount: {
+ currency: "USD",
+ value: 10,
+ },
+ selected: false,
+ },
+ {
+ id: "456",
+ label: "Lightspeed (default)",
+ amount: {
+ currency: "USD",
+ value: 20,
+ },
+ selected: true,
+ },
+ ],
+ },
+ },
+ selectedShippingOption: "456",
+ });
+ await asyncElementRendered();
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 2, "Check dropdown has both options");
+ ok(options[0].textContent.includes("Carrier Pigeon"), "Check first option");
+ is(options[0].getAttribute("amount-currency"), "USD", "Check currency");
+ ok(options[1].textContent.includes("Lightspeed (default)"), "Check second option");
+ is(picker1.dropdown.selectedOption, options[1], "Lightspeed selected by default");
+
+ let selectedClone = picker1.dropdown.querySelector(".rich-select-selected-option");
+ let text = selectedClone.textContent;
+ ok(text.includes("$20.00"),
+ "Shipping option clone should include amount. Value = " + text);
+ ok(text.includes("Lightspeed (default)"),
+ "Shipping option clone should include label. Value = " + text);
+ ok(!isHidden(selectedClone),
+ "Shipping option clone should be visible");
+});
+
+add_task(async function test_update() {
+ picker1.requestStore.setState({
+ request: {
+ paymentDetails: {
+ shippingOptions: [
+ {
+ id: "123",
+ label: "Tortoise",
+ amount: {
+ currency: "CAD",
+ value: 10,
+ },
+ selected: false,
+ },
+ {
+ id: "456",
+ label: "Lightspeed (default)",
+ amount: {
+ currency: "USD",
+ value: 20,
+ },
+ selected: true,
+ },
+ ],
+ },
+ },
+ selectedShippingOption: "456",
+ });
+
+ await promiseStateChange(picker1.requestStore);
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 2, "Check dropdown still has both options");
+ ok(options[0].textContent.includes("Tortoise"), "Check updated first option");
+ is(options[0].getAttribute("amount-currency"), "CAD", "Check currency");
+ ok(options[1].textContent.includes("Lightspeed (default)"), "Check second option is the same");
+ is(picker1.dropdown.selectedOption, options[1], "Lightspeed selected by default");
+});
+
+add_task(async function test_change_selected_option() {
+ let options = picker1.dropdown.popupBox.children;
+ let selectedOption = picker1.dropdown.selectedOption;
+ is(options[1], selectedOption, "Should default to Lightspeed option");
+ is(selectedOption.value, "456", "Selected option should have correct ID");
+ let state = picker1.requestStore.getState();
+ let selectedOptionFromState = state.selectedShippingOption;
+ is(selectedOption.value, selectedOptionFromState,
+ "store's selected option should match selected element");
+
+ let stateChangedPromise = promiseStateChange(picker1.requestStore);
+ picker1.dropdown.popupBox.focus();
+ synthesizeKey(options[0].textContent, {});
+ state = await stateChangedPromise;
+
+ selectedOption = picker1.dropdown.selectedOption;
+ is(selectedOption, options[0], "Selected option should now be the first option");
+ is(selectedOption.value, "123", "Selected option should have correct ID");
+ selectedOptionFromState = state.selectedShippingOption;
+ is(selectedOptionFromState, "123", "store should have first option selected");
+});
+
+add_task(async function test_delete() {
+ let stateChangedPromise = promiseStateChange(picker1.requestStore);
+ picker1.requestStore.setState({
+ request: {
+ paymentDetails: {
+ shippingOptions: [
+ {
+ id: "123",
+ label: "Tortoise",
+ amount: {
+ currency: "CAD",
+ value: 10,
+ },
+ selected: false,
+ },
+ // 456 / Lightspeed was deleted
+ ],
+ },
+ },
+ selectedShippingOption: "123",
+ });
+
+ await stateChangedPromise;
+ let options = picker1.dropdown.popupBox.children;
+ is(options.length, 1, "Check dropdown has one remaining address");
+ ok(options[0].textContent.includes("Tortoise"), "Check remaining address");
+ is(picker1.dropdown.selectedOption, options[0], "Tortoise selected by default");
+});
+</script>
+
+</body>
+</html>