)|T|P}
6 | */
7 | export const selectors = defineTestIdDictionary((testId, testIdRest) => ({
8 | PAGE_LANDING: testId('page', 'landing'),
9 |
10 | FORM_PICK_ADDRESS_TIME: testId('form', 'pick delivery time and address'),
11 | FORM_FIELD_ADDRESS: testId('field', 'text input', 'address'),
12 | FORM_FIELD_TIME: testId('field', 'time input', 'time'),
13 | FORM_FEEDBACK_ADDRESS: testId('feedback', 'error feedback', 'address'),
14 | FORM_FEEDBACK_TIME: testId('feedback', 'error feedback', 'time'),
15 | BTN_SUBMIT_FORM_PICK_ADDRESS_TIME: testId('button', 'submit', 'landing page form'),
16 | ICON_SPIN: testId('icon', 'spinning'),
17 |
18 | PAGE_RESTAURANTS_LIST: testId('page', 'restaurants list'),
19 | TEXT_RESTAURANTS_LIST_SIZE: testId('text', 'restaurants list size'),
20 |
21 | TBL_RESTAURANTS_LIST: testId('table', 'restaurants list'),
22 | CTL_SIZE_PER_PAGE_FOR_TABLE: testId('table nav', 'pagination control', 'size per page'),
23 | CTL_PAGINATION_FOR_TABLE: testId('table nav', 'pagination control', 'pagination buttons'),
24 |
25 | PAGE_RESTAURANT_MENU: testId('page', 'restaurant menu'),
26 | TBL_RESTAURANT_MENU: testId('table', 'restaurant menu'),
27 | TBL_YOUR_TRAY: testId('table', 'your tray'),
28 |
29 | BTN_TO_CHECKOUT: testId('button', 'navigation', 'to checkout'),
30 | BTN_ADD_TO_CART: testId('button', 'add to cart'),
31 | BTN_ADD_TO_CART_FRESH: testId('button', 'add to cart', 'no such item in cart'),
32 | BTN_ADD_TO_CART_ADDED: testId('button', 'add to cart', 'already in cart'),
33 |
34 | INFO_MENU_IS_EMPTY: testId('text', 'menu table is empty'),
35 | INFO_TRAY_IS_EMPTY: testId('text', 'tray table is empty'),
36 | INFO_CART_VALUE: testId('text', 'cart subtotal'),
37 | INFO_CART_VALUE_OF: testIdRest('text', 'cart subtotal'),
38 |
39 | PAGE_CHECKOUT: testId('page', 'checkout'),
40 | PAGE_THANKYOU: testId('page', 'thank you'),
41 |
42 | MODAL_PAYMENT: testId('modal', 'payment'),
43 | BTN_MODAL_PAYMENT_DISMISS_FN: testIdRest('button', 'dismiss payment modal'),
44 | BTN_MODAL_PAYMENT_DISMISS_GENERAL: testId('button', 'dismiss payment modal'),
45 | BTN_MODAL_PAYMENT_DISMISS: testId('button', 'dismiss payment modal', 'dismiss'),
46 | BTN_MODAL_PAYMENT_CANCEL: testId('button', 'dismiss payment modal', 'cancel'),
47 |
48 | FORM_PAYMENT: testId('form', 'payment'),
49 | BTN_FORM_PAYMENT_SUBMIT: testId('button', 'submit payment form'),
50 | TEXT_FORM_PAYMENT_ERRORS: testId('text', 'payment form errors'),
51 | TEXT_FORM_PAYMENT_SUCCESS: testId('text', 'payment form success'),
52 |
53 | FLD_FORM_PAYMENT_FN: testIdRest('field', 'payment form'),
54 | FLD_FORM_PAYMENT_CARD_NUMBER: testId('field', 'payment form', 'card_number'),
55 | FLD_FORM_PAYMENT_EXP_MONTH: testId('field', 'payment form', 'exp_month'),
56 | FLD_FORM_PAYMENT_EXP_YEAR: testId('field', 'payment form', 'exp_year'),
57 | FLD_FORM_PAYMENT_CVV: testId('field', 'payment form', 'cvv'),
58 | FLD_FORM_PAYMENT_ZIP: testId('field', 'payment form', 'zip'),
59 |
60 | BTN_INVOKE_PAYMENT_MODAL: testId('button', 'invoke payment modal'),
61 | BTN_CHECKOUT_MODIFY_CART: testId('button', 'modify cart', 'checkout page'),
62 | BTN_CHECKOUT_REMOVE_ITEM: testId('button', 'remove item', 'checkout page'),
63 | BTN_CHECKOUT_REMOVE_ITEM_FN: testIdRest('button', 'remove item', 'checkout page'),
64 |
65 | CARD_CHECKOUT_ITEM: testId('card', 'item', 'checkout page'),
66 | CARD_CHECKOUT_ITEM_FN: testIdRest('card', 'item', 'checkout page'),
67 |
68 | TEXT_ORDER_ID_FN: testIdRest('text', 'orderId'),
69 | TEXT_ORDER_ID: testId('text', 'orderId')
70 |
71 | }));
72 |
--------------------------------------------------------------------------------
/src/ui/components/SelectedAddressRow.js:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from 'react-redux';
2 | import { accessDeliveryAddress, accessDeliveryTime, resetAddressAndTime } from '../../features/address/addressSlice';
3 | import { useCallback, useEffect } from 'react';
4 | import { navigateToEditDeliveryAddress } from '../../features/actions/navigation';
5 | import { Col, Container } from 'reactstrap';
6 | import { RoundedButton } from '../elements/formElements';
7 | import { Span } from '../elements/textElements';
8 | import { IconGeo, IconEdit } from '../elements/icons';
9 |
10 | export const SelectedAddressRow = () => {
11 |
12 | const dispatch = useDispatch();
13 | const deliveryAddress = useSelector(accessDeliveryAddress());
14 | const deliveryTime = useSelector(accessDeliveryTime());
15 |
16 | useEffect(() => {
17 | if (deliveryAddress && deliveryTime) {
18 | return;
19 | }
20 | dispatch(resetAddressAndTime());
21 | dispatch(navigateToEditDeliveryAddress());
22 | }, [ deliveryAddress, deliveryTime, dispatch ]);
23 |
24 | const handleEditAddress = useCallback(() => {
25 | dispatch(navigateToEditDeliveryAddress());
26 | }, [ dispatch ]);
27 |
28 |
29 | return (
30 |
31 |
32 | Deliver To:
33 | { deliveryAddress }
34 |
35 | Deliver At:
36 |
37 | { deliveryTime }
38 |
39 |
40 |
41 |
42 |
);
43 | };
44 |
--------------------------------------------------------------------------------
/src/ui/components/SelectedRestaurantRow.js:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from 'react-redux';
2 | import { accessSelectedRestaurantId } from '../../features/restaurants/restaurantsSlice';
3 | import { Col, Container } from 'reactstrap';
4 | import { RoundedButton } from '../elements/formElements';
5 | import { Span } from '../elements/textElements';
6 | import { IconEdit, IconGeo, IconClock } from '../elements/icons';
7 | import { useCallback } from 'react';
8 | import { navigateToPickRestaurants } from '../../features/actions/navigation';
9 | import { accessRestaurantInfo } from '../../features/address/addressSlice';
10 |
11 | export function SelectedRestaurantRow() {
12 |
13 | const dispatch = useDispatch();
14 | const selectedRestaurantId = useSelector(accessSelectedRestaurantId());
15 | const selectedRestaurant = useSelector(accessRestaurantInfo(selectedRestaurantId));
16 | const handleChangeRestaurant = useCallback(() => {
17 | dispatch(navigateToPickRestaurants());
18 | }, [ dispatch ]);
19 |
20 | return (
21 |
22 |
23 | Deliver From:
24 | { selectedRestaurant?.address ?? 'No Address' }
25 |
26 | Restaurant:
27 | { selectedRestaurant?.name ?? 'No Restaurant' }
28 |
29 | ETA:
30 | { selectedRestaurant?.avgDeliveryTime }
31 |
32 |
33 |
34 |
);
35 | }
36 |
--------------------------------------------------------------------------------
/src/ui/elements/Loading.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import React from 'react';
3 | import cx from 'classnames';
4 | import { IconRefresh } from './icons';
5 |
6 | export const Loading = styled.div`
7 | min-height: 80vh;
8 | text-align: center;
9 | vertical-align: center;
10 | display: flex;
11 | flex-direction: column;
12 | justify-content: center;
13 | align-content: center;
14 | border-radius: 2rem;
15 | font-size: 1.5rem;
16 | `;
17 |
18 | export const LoadingSpinner = ({ inline }) =>
19 |
20 |
21 |
;
22 |
--------------------------------------------------------------------------------
/src/ui/elements/Snippet/Snippet.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import '@testing-library/jest-dom/extend-expect';
3 | import { cleanup, fireEvent, render, waitForDomChange } from '@testing-library/react';
4 | import { Provider } from 'react-redux';
5 | import { history, store } from '../../../app/store';
6 | import { ConnectedRouter } from 'connected-react-router';
7 | import { Snippet } from './index';
8 |
9 | // from: https://github.com/popperjs/popper-core/issues/478
10 | // How to use Popper.js in Jest / JSDOM?
11 | jest.mock(
12 | 'popper.js',
13 | () =>
14 | class Popper {
15 | static placements = [
16 | 'auto', 'auto-end', 'auto-start',
17 | 'bottom', 'bottom-end', 'bottom-start',
18 | 'left', 'left-end', 'left-start',
19 | 'right', 'right-end', 'right-start',
20 | 'top', 'top-end', 'top-start'
21 | ];
22 |
23 | constructor() {
24 | return {
25 | destroy: () => {},
26 | scheduleUpdate: () => {}
27 | };
28 | }
29 | }
30 | );
31 |
32 | describe(`src/ui/elements/Snippet/index.js`, () => {
33 | describe(`Snippet component`, () => {
34 |
35 | afterEach(cleanup);
36 |
37 | test('renders a Snippet component', async () => {
38 | const { getByText, getAllByText } = render(
39 |
40 |
41 |
42 |
43 |
44 | );
45 |
46 | expect(getByText(/lollapalooza/i)).toBeInTheDocument();
47 | expect(getAllByText(/Copy/i)[ 0 ]).toBeInTheDocument();
48 |
49 | getAllByText(/Copy/i)[ 0 ].focus();
50 | fireEvent.click(getAllByText(/Copy/i)[ 0 ]);
51 | await waitForDomChange();
52 |
53 | expect(getByText(/Successfully copied/i)).toBeInTheDocument();
54 |
55 | });
56 |
57 |
58 | });
59 |
60 | });
61 |
--------------------------------------------------------------------------------
/src/ui/elements/Snippet/index.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2 | import { useCopyToClipboard } from 'react-use';
3 | import { UncontrolledTooltip } from 'reactstrap';
4 | import { If } from '../conditions';
5 | import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
6 | import js from 'react-syntax-highlighter/dist/esm/languages/hljs/javascript';
7 | import bash from 'react-syntax-highlighter/dist/esm/languages/hljs/bash';
8 | import docco from 'react-syntax-highlighter/dist/esm/styles/hljs/docco';
9 | import './snippet.scss';
10 |
11 | SyntaxHighlighter.registerLanguage('javascript', js);
12 | SyntaxHighlighter.registerLanguage('bash', bash);
13 |
14 |
15 | const COPIED_MESSAGE_LIFE = 5000;
16 |
17 | export const Snippet = ({ messageToCopy, lang, style = docco }) => {
18 |
19 | const initialized = useRef(true);
20 |
21 | const [ copiedTs, setCopiedTs ] = useState(0);
22 | const [ copyState, copyToClipboard ] = useCopyToClipboard();
23 |
24 | const copyBtnHandler = useCallback(() => {
25 | copyToClipboard(messageToCopy);
26 | }, [ copyToClipboard, messageToCopy ]);
27 |
28 | useEffect(() => {
29 | if (copyState.error || !copyState.value) {
30 | return;
31 | }
32 | setCopiedTs(new Date() - 0);
33 | setTimeout(() => {
34 | if (!initialized.current) {
35 | return;
36 | }
37 | setCopiedTs(0);
38 | }, COPIED_MESSAGE_LIFE);
39 | }, [ copyState, copyState.error ]);
40 |
41 | useEffect(() => {
42 | // unloading
43 | return () => {
44 | initialized.current = false;
45 | };
46 | }, []);
47 |
48 | const elapsedSinceCopied = new Date() - copiedTs;
49 |
50 | const buttonId = useMemo(() => `id_${ Math.random().toString().substring(2) }`, []);
51 |
52 | return (<>
53 |
54 | Copy
56 |
57 | COPIED_MESSAGE_LIFE }>Copy
59 | to clipboard Successfully
60 | copied!
61 |
62 |
63 |
64 | { messageToCopy }
65 |
66 | >);
67 | };
68 |
--------------------------------------------------------------------------------
/src/ui/elements/Snippet/snippet.scss:
--------------------------------------------------------------------------------
1 | @import "~bootstrap/scss/functions";
2 | @import "~bootstrap/scss/variables";
3 | @import "~bootstrap/scss/mixins";
4 |
5 | .highlight {
6 | padding: 1rem !important;
7 | padding-top: 2rem !important;
8 | margin-top: 1rem;
9 | margin-bottom: 1rem;
10 | background-color: $gray-100;
11 | -ms-overflow-style: -ms-autohiding-scrollbar;
12 | font-size: .89rem !important;
13 |
14 | @include media-breakpoint-up(sm) {
15 | padding: 1.5rem !important;
16 | padding-top: 2rem !important;
17 | }
18 | }
19 |
20 | .bd-content .highlight {
21 | margin-right: (-$grid-gutter-width / 2);
22 | margin-left: (-$grid-gutter-width / 2);
23 |
24 | @include media-breakpoint-up(sm) {
25 | margin-right: 0;
26 | margin-left: 0;
27 | }
28 | }
29 |
30 | .highlight {
31 |
32 | pre {
33 | padding: 0;
34 | margin-top: .65rem;
35 | margin-bottom: .65rem;
36 | background-color: transparent;
37 | border: 0;
38 | }
39 | pre code {
40 | @include font-size(inherit);
41 | color: $gray-900; // Effectively the base text color
42 | }
43 | }
44 |
45 | .bd-clipboard {
46 | position: relative;
47 | display: none;
48 | float: right;
49 |
50 | + .highlight {
51 | margin-top: 0;
52 | }
53 |
54 | @include media-breakpoint-up(md) {
55 | display: block;
56 | }
57 | }
58 |
59 | .btn-clipboard {
60 | position: absolute;
61 | top: .65rem;
62 | right: .65rem;
63 | z-index: 10;
64 | display: block;
65 | padding: .25rem .5rem;
66 | @include font-size(65%);
67 | color: $primary;
68 | background-color: $white;
69 | border: 1px solid;
70 | @include border-radius();
71 |
72 | &:hover {
73 | color: $white;
74 | background-color: $primary;
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/ui/elements/conditions.js:
--------------------------------------------------------------------------------
1 | //import React from 'react';
2 | export const If = ({ condition, children, elseIf }) => condition ? (<>{ children }>) : (elseIf || null);
3 | //export const ElseIf = ({ condition, children }) => condition ? null : (<>{ children }>);
4 |
--------------------------------------------------------------------------------
/src/ui/elements/errorBoundary.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import { Snippet } from './Snippet';
4 | import { Container } from 'reactstrap';
5 |
6 | export class ErrorBoundary extends React.Component {
7 | constructor(props) {
8 | super(props);
9 | this.state = { hasError: false };
10 | this.handleDismissError = this.handleDismissError.bind(this);
11 | this.handleInvokeAssistance = this.handleInvokeAssistance.bind(this);
12 | }
13 |
14 | static getDerivedStateFromError(error) {
15 | return { hasError: true, error };
16 | }
17 |
18 | componentDidCatch(error, info) {
19 | this.setState({ error, info });
20 | }
21 |
22 | handleDismissError() {
23 | this.setState({ hasError: false, error: null, info: null });
24 | // if (this.props.dispatch) {
25 | //// this.props.dispatch(navigateToAppsList());
26 | // }
27 | }
28 |
29 | handleInvokeAssistance() {
30 | // if (this.props.dispatch) {
31 | //// this.props.dispatch(together(modalInvoked, gaModalInvoked)('assist'));
32 | // }
33 | }
34 |
35 | render() {
36 | if (!this.state.hasError) {
37 | return this.props.children;
38 | }
39 |
40 | const errorText = String(this.state.error);
41 |
42 | return
43 | Something went wrong
44 | Please try to reload the page. If the problem persists please seek support
45 | using the copy-pasted information below to describe what went wrong and by clicking the ? Help button
47 |
48 |
49 | Error:
50 |
51 |
52 |
53 |
54 |
55 | Dismiss
56 |
57 |
58 | ;
59 | }
60 | }
61 |
62 | export default connect()(ErrorBoundary);
63 |
--------------------------------------------------------------------------------
/src/ui/elements/errorBoundary.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import '@testing-library/jest-dom/extend-expect';
3 | import { cleanup, render, wait } from '@testing-library/react';
4 | import { Provider } from 'react-redux';
5 | import { history, store } from '../../app/store';
6 | import { ConnectedRouter } from 'connected-react-router';
7 | import { ErrorBoundary } from './errorBoundary';
8 |
9 | describe(`src/ui/elements/errorBoundary.js`, () => {
10 | describe(`ErrorBoundary component`, () => {
11 |
12 | afterEach(cleanup);
13 |
14 | test('renders ErrorBoundary component', async () => {
15 | const ThrowingComponent = () => {
16 | return {};
17 | };
18 |
19 | const { getByText } = render(
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 |
29 | await wait();
30 |
31 | expect(getByText(/Something went wrong/i)).toBeInTheDocument();
32 | });
33 |
34 |
35 | });
36 |
37 | });
38 |
--------------------------------------------------------------------------------
/src/ui/elements/formElements.js:
--------------------------------------------------------------------------------
1 | import ReactDOMServer from 'react-dom/server';
2 | import React from 'react';
3 | import styled from 'styled-components';
4 | import { Button, Input } from 'reactstrap';
5 |
6 | export const RoundedButton = styled(Button)`
7 | border-radius: 1rem;
8 | `;
9 | const RoundedInput = styled(Input)`
10 | border-radius: 1rem;
11 | `;
12 | const RoundedInputWithIcon = styled(RoundedInput)`
13 | padding-left: calc(1.5em + 0.75rem);
14 | background-repeat: no-repeat;
15 | background-position: left calc(0.375em + 0.1875rem) center;
16 | background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
17 | `;
18 |
19 | export function InputWithIcon({ icon, ...props }) {
20 | const iconMarkup = ReactDOMServer.renderToStaticMarkup(icon);
21 |
22 | if (!iconMarkup) {
23 | return ;
24 | }
25 | return ;
31 | }
32 |
--------------------------------------------------------------------------------
/src/ui/elements/icons.js:
--------------------------------------------------------------------------------
1 | import { FaSync as FaRefresh } from 'react-icons/fa';
2 | import { FaHamburger, FaMapMarkerAlt, FaClock, FaSearch, FaEdit, FaPlus, FaMinus, FaCartPlus, FaTrashAlt } from 'react-icons/fa';
3 | import { FaChevronRight } from 'react-icons/fa'
4 | import React from 'react';
5 | import theme from './icons.module.scss'
6 | import { e2eAssist } from '../../testability';
7 |
8 | export const IconRefresh = () => ;
9 | export const IconLogo = FaHamburger;
10 | export const IconGeo = FaMapMarkerAlt;
11 | export const IconClock = FaClock;
12 | export const IconSearch = FaSearch;
13 | export const IconEdit = FaEdit;
14 | export const IconPlus = FaPlus;
15 | export const IconTrash = FaTrashAlt;
16 | export const IconCartPlus = FaCartPlus;
17 | export const IconMinus = FaMinus;
18 | export const IconChevronRight = FaChevronRight;
19 |
--------------------------------------------------------------------------------
/src/ui/elements/icons.module.scss:
--------------------------------------------------------------------------------
1 | .icon-spin {
2 | animation: icon-spin 2s infinite linear;
3 | }
4 |
5 |
6 | @keyframes icon-spin {
7 | 0% {
8 | transform: rotate(0deg);
9 | }
10 | 100% {
11 | transform: rotate(359deg);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/ui/elements/paginatedTable.js:
--------------------------------------------------------------------------------
1 | import BootstrapTable from 'react-bootstrap-table-next';
2 | import paginationFactory, {
3 | PaginationListStandalone,
4 | PaginationProvider,
5 | SizePerPageDropdownStandalone
6 | } from 'react-bootstrap-table2-paginator';
7 | import './reactBootstrapTableCustomization.scss';
8 | import React from 'react';
9 | import { e2eAssist } from '../../testability';
10 |
11 | export const PaginatedTable = ({
12 | data,
13 | columns,
14 | keyField,
15 | noPagination,
16 | paginationOnTop,
17 | paginationFactoryOptions,
18 | 'data-testid': dataTestIdAttr,
19 | ...tableProps
20 | }) =>
21 | noPagination ?
22 |
:
28 | {
32 | ({
33 | paginationProps,
34 | paginationTableProps // : { pagination: { options, ...restPagination } }
35 | }) => (
36 |
58 | )
59 | } ;
60 |
--------------------------------------------------------------------------------
/src/ui/elements/reactBootstrapTableCustomization.scss:
--------------------------------------------------------------------------------
1 | .selection-cell-header, .selection-cell {
2 | display: none;
3 | }
4 |
--------------------------------------------------------------------------------
/src/ui/elements/textElements.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Span = styled.span`
4 | ${ props => props.vaMiddle ? `vertical-align: middle;` : '' }
5 | ${ props => props.centerEditIcon ? `transform: translate(2px, -2px); display: inline-block;` : '' }
6 | `;
7 |
8 | export const LargeTextDiv = styled.div`
9 | font-size: 4rem;
10 | font-weight: 800;
11 | color: rgba(0, 0, 0, .75);
12 | `;
13 |
14 | export const LessLargeTextDiv = styled.div`
15 | font-size: ${ props => props.size || 3 }rem;
16 | font-weight: 800;
17 | color: rgba(0, 0, 0, .8);
18 | `;
19 |
--------------------------------------------------------------------------------
/src/ui/pages/AppLayout/AppLayout.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import '@testing-library/jest-dom/extend-expect';
3 | import { render, wait, cleanup } from '@testing-library/react';
4 | import { Provider } from 'react-redux';
5 | import { history, store } from '../../../app/store';
6 | import AppLayout from '../AppLayout';
7 | import { ConnectedRouter } from 'connected-react-router';
8 |
9 | describe(`src/ui/pages/AppLayout/AppLayout.test.js`, () => {
10 |
11 | afterEach(cleanup);
12 |
13 | test('renders ftgo application', async () => {
14 | const { getByText } = render(
15 |
16 |
17 |
18 |
19 |
20 | );
21 |
22 | await wait();
23 |
24 | expect(getByText(/FTGO Application/i)).toBeInTheDocument();
25 | });
26 |
27 |
28 | });
29 |
--------------------------------------------------------------------------------
/src/ui/pages/AppLayout/RootRoutes.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { getAppsRoutes } from '../AppRoutes';
3 |
4 | export const RootRoutes = () => {
5 | const isAuthed = useState(true);
6 | if (!isAuthed) {
7 | return null;
8 | }
9 | return getAppsRoutes();
10 | };
11 |
--------------------------------------------------------------------------------
/src/ui/pages/AppLayout/appLayout.css:
--------------------------------------------------------------------------------
1 | .navbar-shadow {
2 | box-shadow: 0 0.25rem 0.5rem rgb(0 0 0 / 18%);
3 | }
4 |
--------------------------------------------------------------------------------
/src/ui/pages/AppLayout/index.js:
--------------------------------------------------------------------------------
1 | import { Container, Nav, Navbar, NavbarBrand, NavItem, NavLink } from 'reactstrap';
2 | import styled from 'styled-components';
3 | import { ErrorBoundary } from '../../elements/errorBoundary';
4 | import { RootRoutes } from './RootRoutes';
5 | import 'bootstrap/scss/bootstrap.scss';
6 | import { IconLogo } from '../../elements/icons';
7 | import './appLayout.css';
8 | import { NavLink as RoutingLink } from 'react-router-dom';
9 | import { routePaths } from '../AppRoutes/routePaths';
10 | import { accessIsLoading } from '../../../features/ui/loadingSlice';
11 | import { useSelector } from 'react-redux';
12 | import { LoadingSpinner } from '../../elements/Loading';
13 | import { Span } from '../../elements/textElements';
14 |
15 |
16 | const CustomizedNavbarBrand = styled(NavbarBrand)`
17 | white-space: inherit;
18 | color: var(--black);
19 | font-size: 2rem;
20 | font-weight: 600;
21 | `;
22 |
23 | export const AppLayout = () => {
24 |
25 | const isLoading = useSelector(accessIsLoading());
26 |
27 | return
28 |
29 | {
30 | isLoading &&
31 |
32 |
33 | }
34 |
35 |
36 |
37 |
38 | FTGO
39 |
40 |
41 |
42 | Address
43 |
44 |
45 | Restaurant
46 |
47 |
48 | Dish
49 |
50 |
51 | Order
52 |
53 |
54 | UserButton (Logged in)
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
;
67 | };
68 |
69 | export default AppLayout;
70 |
--------------------------------------------------------------------------------
/src/ui/pages/AppRoutes/index.js:
--------------------------------------------------------------------------------
1 | import React, { lazy, Suspense } from 'react';
2 | import { Loading, LoadingSpinner } from '../../elements/Loading';
3 | import { Redirect, Route, Switch, useLocation } from 'react-router-dom';
4 | import { routePaths } from './routePaths';
5 |
6 |
7 | const LandingPage = lazy(() => import(/* webpackChunkName: "landing" */ '../LandingPage'));
8 | const LoginPage = lazy(() => import(/* webpackChunkName: "login" */ '../LoginPage'));
9 | const RestaurantListPage = lazy(() => import(/* webpackChunkName: "places" */ '../RestaurantListPage'));
10 | const RestaurantPage = lazy(() => import(/* webpackChunkName: "place" */ '../RestaurantPage'));
11 | const CheckoutPage = lazy(() => import(/* webpackChunkName: "checkout" */ '../CheckoutPage'));
12 | const ThankYouPage = lazy(() => import(/* webpackChunkName: "thankyou" */ '../ThankYouPage'));
13 |
14 | const repackWithComponentRender = ([ path, Component ]) => [ path, ({
15 | path,
16 | render: props =>
17 | }) ];
18 |
19 | const routesMap = new Map([
20 | [ routePaths.landing, LandingPage ],
21 | [ routePaths.restaurants, RestaurantListPage ],
22 | [ routePaths.restaurant, RestaurantPage ],
23 | [ routePaths.checkout, CheckoutPage ],
24 | [ routePaths.thankyou, ThankYouPage ],
25 | [ routePaths.login, LoginPage ]
26 | ].map(repackWithComponentRender));
27 |
28 | const routes = [
29 | routePaths.landing,
30 | routePaths.login,
31 | routePaths.restaurant,
32 | routePaths.restaurants,
33 | routePaths.checkout,
34 | routePaths.thankyou,
35 | ].filter(Boolean)
36 | .map(item => typeof item === 'string' ?
37 | :
38 | item);
39 |
40 |
41 | function Routes() {
42 | return
43 |
44 | { routes }
45 |
46 |
47 |
48 |
49 | ;
50 | }
51 |
52 | export function getAppsRoutes() {
53 | return }> ;
54 | }
55 |
56 | function NoMatch() {
57 | let location = useLocation();
58 |
59 | return (
60 |
61 |
62 | No match for { location.pathname }
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/ui/pages/AppRoutes/routePaths.js:
--------------------------------------------------------------------------------
1 | export const routePaths = {
2 | landing: '/start',
3 | restaurants: '/place',
4 | restaurant: '/place/:placeId',
5 | checkout: '/checkout',
6 | thankyou: '/thankyou',
7 | login: '/login'
8 | };
9 |
--------------------------------------------------------------------------------
/src/ui/pages/CheckoutPage/cardElement.js:
--------------------------------------------------------------------------------
1 | import { Input, InputGroup, InputGroupText } from 'reactstrap';
2 | import React, { useCallback, useEffect, useMemo, useState } from 'react';
3 | import curry from 'lodash-es/curry';
4 | import { accessCardValue, resetCard, updateCardValue } from '../../../features/card/cardSlice';
5 | import { useDispatch, useSelector } from 'react-redux';
6 | import { postConfirmPaymentAsyncThunk } from '../../../features/cart/cartSlice';
7 | import { e2eAssist } from '../../../testability';
8 |
9 | export const useElements = () => {
10 | const cardValue = useSelector(accessCardValue());
11 | const getCardValue = useCallback(() => cardValue, [ cardValue ]);
12 | return useMemo(() => ({
13 | getCardValue
14 | }), [ getCardValue ]);
15 | };
16 |
17 | export const useStripe = () => {
18 | const dispatch = useDispatch();
19 |
20 | return useMemo(() => ({
21 | async confirmCardPayment(clientSecret, data) {
22 | const {
23 | payment_method: {
24 | card //: elements.getElement(CardElement)
25 | }
26 | } = data;
27 |
28 | console.log('[stripe.confirmCardPayment]', clientSecret, card);
29 |
30 | const response = await dispatch(postConfirmPaymentAsyncThunk({ clientSecret, card }));
31 | const { error, payload } = response;
32 | console.log(error, payload);
33 | return payload;
34 | }
35 | }), [ dispatch ]);
36 | };
37 |
38 | export const CardElement = ({ errors, onChange, options = {} }) => {
39 |
40 | const [ ccNumber, setCCNumber ] = useState('');
41 | const [ expMonth, setExpMonth ] = useState('');
42 | const [ expYear, setExpYear ] = useState('');
43 | const [ cvv, setCvv ] = useState('');
44 | const [ zip, setZip ] = useState('');
45 |
46 | const { style } = options;
47 | const baseStyle = style?.base ?? {};
48 | const invalidStyle = style?.invalid ?? {};
49 |
50 | const onChangeHandler = useMemo(() => curry((setter, evt) => {
51 | setter(evt.target.value);
52 | }), []);
53 |
54 | const dispatch = useDispatch();
55 |
56 | useEffect(() => {
57 | dispatch(resetCard());
58 | }, [ dispatch ]);
59 |
60 | const isEmpty = useMemo(() => [ ccNumber, cvv, expMonth, expYear, zip ].every(val => !val),
61 | [ ccNumber, cvv, expMonth, expYear, zip ]);
62 |
63 | useEffect(() => {
64 |
65 | const card = {
66 | card_number: ccNumber,
67 | exp_month: expMonth,
68 | exp_year: expYear,
69 | cvv,
70 | zip
71 | };
72 | onChange && onChange({
73 | empty: isEmpty,
74 | error: null,
75 | card
76 | });
77 | dispatch(updateCardValue({ card, isEmpty }));
78 | }, [ ccNumber, cvv, dispatch, expMonth, expYear, isEmpty, onChange, zip ]);
79 |
80 | return <>
81 |
86 |
87 |
92 | /
93 |
98 |
99 |
100 |
105 |
110 |
111 |
112 | Try using these values for the card:
113 |
114 | 4242 4242 4242 4242 - Payment succeeds
115 |
116 | 4000 0025 0000 3155 - Payment requires authentication
117 |
118 |
119 | 4000 0000 0000 9995 - Payment is declined
120 |
121 |
122 | >
123 | ;
124 | };
125 |
126 |
--------------------------------------------------------------------------------
/src/ui/pages/CheckoutPage/checkoutForm.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useState } from 'react';
2 | import { CardElement, useElements, useStripe } from './cardElement';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import {
5 | accessCartItems, accessVerboseCartInfo,
6 | paymentSuccessful,
7 | postCreatePaymentIntentAsyncThunk
8 | } from '../../../features/cart/cartSlice';
9 | import { LoadingSpinner } from '../../elements/Loading';
10 | import './checkoutForm.scss';
11 | import { safelyExecuteSync } from '../../../shared/promises';
12 | import { e2eAssist } from '../../../testability';
13 |
14 |
15 | export function CheckoutForm() {
16 |
17 | const [ succeeded, setSucceeded ] = useState(false);
18 | const [ error, setError ] = useState(null);
19 | const [ errors, setErrors ] = useState(null);
20 | const [ processing, setProcessing ] = useState('');
21 | const [ disabled, setDisabled ] = useState(true);
22 | const [ clientSecret, setClientSecret ] = useState('');
23 |
24 | const stripe = useStripe();
25 | const elements = useElements();
26 | const cartItems = useSelector(accessCartItems());
27 | const verboseCartInfo = useSelector(accessVerboseCartInfo());
28 |
29 | const dispatch = useDispatch();
30 | const handleCreatePaymentIntent = useCallback(async (items) => {
31 | // POST api/payment/intent:
32 | const { payload } = await dispatch(postCreatePaymentIntentAsyncThunk({ items }));
33 | setClientSecret(payload?.clientSecret ?? '');
34 | }, [ dispatch ]);
35 |
36 | useEffect(() => {
37 | // Create PaymentIntent as soon as the page loads
38 | void handleCreatePaymentIntent(cartItems);
39 | setSucceeded(false);
40 | }, [ cartItems, handleCreatePaymentIntent ]);
41 |
42 | const cardStyle = {
43 | style: {
44 | base: {
45 | color: '#32325d',
46 | fontFamily: 'Arial, sans-serif',
47 | fontSmoothing: 'antialiased',
48 | fontSize: '16px',
49 | '::placeholder': {
50 | color: '#32325d'
51 | }
52 | },
53 | invalid: {
54 | color: '#fa755a',
55 | iconColor: '#fa755a'
56 | }
57 | }
58 | };
59 |
60 | const handleChange = useCallback(async (event) => {
61 | // Listen for changes in the CardElement
62 | // and display any errors as the customer types their card details
63 | setDisabled(event.empty);
64 | setError(event.error ? event.error.message : '');
65 | }, []);
66 |
67 | const handleSubmit = useCallback(async ev => {
68 |
69 | ev.preventDefault();
70 | setProcessing(true);
71 |
72 | const [ err, card ] = safelyExecuteSync(() => elements.getCardValue())();
73 | if (err) {
74 | console.log(err);
75 | debugger;
76 | throw err;
77 | }
78 |
79 | const payload = await stripe.confirmCardPayment(clientSecret, {
80 | payment_method: {
81 | card //: elements.getElement(CardElement)
82 | }
83 | });
84 |
85 | console.log(payload);
86 | debugger;
87 |
88 | if (payload.error) {
89 | setError(`${ payload.error.message }`);
90 | payload.errors ? setErrors(payload.errors) : setErrors(null);
91 | setProcessing(false);
92 | } else {
93 | setError(null);
94 | setErrors(null);
95 | setProcessing(false);
96 | setSucceeded(true);
97 | dispatch(paymentSuccessful());
98 | }
99 | }, [ clientSecret, dispatch, elements, stripe ]);
100 |
101 | if (!clientSecret) {
102 | return null;
103 | }
104 |
105 | return (
106 |
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/src/ui/pages/CheckoutPage/checkoutForm.scss:
--------------------------------------------------------------------------------
1 |
2 | #card-error {
3 | color: rgb(105, 115, 134);
4 | font-size: 16px;
5 | line-height: 20px;
6 | margin-top: 12px;
7 | text-align: center;
8 | }
9 | #card-element {
10 | border-radius: 4px 4px 0 0;
11 | padding: 12px;
12 | border: 1px solid rgba(50, 50, 93, 0.1);
13 | max-height: 44px;
14 | width: 100%;
15 | background: white;
16 | box-sizing: border-box;
17 | }
18 | #payment-request-button {
19 | margin-bottom: 32px;
20 | }
21 |
22 | .result-message {
23 | line-height: 22px;
24 | font-size: 16px;
25 | }
26 | .result-message a {
27 | color: rgb(89, 111, 214);
28 | font-weight: 600;
29 | text-decoration: none;
30 | }
31 |
32 |
33 | /* Buttons and links */
34 | button#submit {
35 | background: #5469d4;
36 | font-family: Arial, sans-serif;
37 | color: #ffffff;
38 | border-radius: 4px;
39 | border: 0;
40 | padding: 12px 16px;
41 | font-size: 16px;
42 | font-weight: 600;
43 | cursor: pointer;
44 | display: block;
45 | transition: all 0.2s ease;
46 | box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
47 | width: 100%;
48 | }
49 | button#submit:hover {
50 | filter: contrast(115%);
51 | }
52 | button#submit:disabled {
53 | opacity: 0.5;
54 | cursor: default;
55 | }
56 |
--------------------------------------------------------------------------------
/src/ui/pages/CheckoutPage/index.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useState } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import {
4 | accessCart,
5 | accessCartItems,
6 | accessCartStatus,
7 | accessPaymentSuccessful,
8 | accessVerboseCartInfo
9 | } from '../../../features/cart/cartSlice';
10 | import {
11 | navigateToEditDeliveryAddress,
12 | navigateToEditMenu,
13 | navigateToPickRestaurants,
14 | navigateToThankYou
15 | } from '../../../features/actions/navigation';
16 | import { Button, Col, Container, Row } from 'reactstrap';
17 | import { accessSelectedRestaurantId } from '../../../features/restaurants/restaurantsSlice';
18 | import { e2eAssist } from '../../../testability';
19 | import { SelectedAddressRow } from '../../components/SelectedAddressRow';
20 | import { SelectedRestaurantRow } from '../../components/SelectedRestaurantRow';
21 | import { YourTrayItems } from '../RestaurantPage/yourTrayItems';
22 | import { OrderInfo } from './orderInfo';
23 | import { PaymentModal } from './paymentModal';
24 | import { accessIsLoading } from '../../../features/ui/loadingSlice';
25 |
26 |
27 | const CheckoutPage = () => {
28 | const dispatch = useDispatch();
29 | const cartStatus = useSelector(accessCartStatus());
30 | const cartItems = useSelector(accessCartItems());
31 | const cartId = useSelector(accessCart('id'));
32 | const selectedRestaurantId = useSelector(accessSelectedRestaurantId());
33 | const verboseCartInfo = useSelector(accessVerboseCartInfo());
34 | const recentPaymentSuccess = useSelector(accessPaymentSuccessful());
35 | const isLoading = useSelector(accessIsLoading());
36 |
37 | useEffect(() => {
38 | if (!selectedRestaurantId) {
39 | return;
40 | }
41 | if (cartItems.length) {
42 | return;
43 | }
44 | dispatch(navigateToEditMenu(selectedRestaurantId));
45 | }, [ cartItems.length, dispatch, selectedRestaurantId ]);
46 |
47 | useEffect(() => {
48 | if (!selectedRestaurantId) {
49 | dispatch(navigateToEditDeliveryAddress());
50 | }
51 | }, [ dispatch, selectedRestaurantId ]);
52 |
53 | const handleChangeTray = useCallback(() => {
54 | dispatch(navigateToEditMenu(selectedRestaurantId));
55 | }, [ dispatch, selectedRestaurantId ]);
56 |
57 | const [ showPaymentModal, setShowPaymentModal ] = useState(false);
58 |
59 | const toggle = useCallback(() => {
60 | setShowPaymentModal(!showPaymentModal);
61 | if (recentPaymentSuccess !== true) {
62 | debugger;
63 | return;
64 | }
65 | dispatch(navigateToThankYou());
66 | }, [ dispatch, recentPaymentSuccess, showPaymentModal ]);
67 |
68 | const handleRequestPayment = useCallback(() => {
69 | setShowPaymentModal(true);
70 | }, []);
71 |
72 |
73 | useEffect(() => {
74 | if (!showPaymentModal) {
75 | return;
76 | }
77 | // fetch("/create-payment-intent", {
78 | // method: "POST",
79 | // headers: {
80 | // "Content-Type": "application/json"
81 | // },
82 | // body: JSON.stringify(purchase)
83 | //})
84 | }, [ showPaymentModal ]);
85 |
86 | useEffect(() => {
87 | if (cartId || (cartStatus !== 'ready')) {
88 | return null;
89 | }
90 | if (selectedRestaurantId) {
91 | //dispatch(navigateToEditMenu(selectedRestaurantId));
92 | void dispatch;
93 | void navigateToEditMenu;
94 | void selectedRestaurantId;
95 | } else {
96 | //dispatch(navigateToPickRestaurants());
97 | void navigateToPickRestaurants;
98 | }
99 | }, [ cartId, cartStatus, dispatch, selectedRestaurantId ]);
100 |
101 | if (!cartId || (cartStatus !== 'ready')) {
102 | debugger;
103 | return null;
104 | }
105 |
106 | return
107 |
108 |
109 |
110 |
111 |
112 | Your Order
113 |
114 |
115 |
116 |
117 | Items:
118 |
119 |
120 |
121 |
122 |
123 |
124 | + Add More Items
125 |
126 |
127 |
128 | Summary:
129 |
130 |
131 |
132 |
133 |
134 |
135 | Pay { String(verboseCartInfo.total ?? '') }
137 |
138 |
139 |
140 |
141 |
;
142 | };
143 |
144 | export default CheckoutPage;
145 |
--------------------------------------------------------------------------------
/src/ui/pages/CheckoutPage/orderInfo.js:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { accessCart, accessCartInfo, accessCartStatus, accessVerboseCartInfo } from '../../../features/cart/cartSlice';
3 |
4 | export const OrderInfo = () => {
5 |
6 | const orderId = useSelector(accessCart('orderId'));
7 | const cartStatus = useSelector(accessCartStatus());
8 | const verboseCartInfo = useSelector(accessVerboseCartInfo());
9 | const cartInfo = useSelector(accessCartInfo());
10 | console.log('[cartInfo]', cartInfo);
11 |
12 | void orderId;
13 | void cartStatus;
14 |
15 | const { total, subTotal, tax, delivery } = verboseCartInfo;
16 | const { taxAmount } = cartInfo;
17 |
18 | return <>
19 |
20 |
Subtotal:
21 |
{ subTotal }
22 |
23 |
24 |
Delivery Fee:
25 |
{ delivery }
26 |
27 |
28 |
Fees & Estimated Tax ({ (100 * taxAmount).toFixed(2) }%):
29 |
{ tax }
30 |
31 |
32 |
Total:
33 |
{ total }
34 |
35 | >;
36 | };
37 |
--------------------------------------------------------------------------------
/src/ui/pages/CheckoutPage/paymentModal.js:
--------------------------------------------------------------------------------
1 | import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
2 | import React from 'react';
3 | import { CheckoutForm } from './checkoutForm';
4 | import { e2eAssist } from '../../../testability';
5 |
6 | //import { Elements } from '@stripe/react-stripe-js';
7 | //import { loadStripe } from '@stripe/stripe-js';
8 | //const stripePromise = loadStripe(ensureEnvVariable('REACT_APP_STRIPE_PK_KEY'));
9 |
10 | export const PaymentModal = ({ show, toggle, showDismiss }) => {
11 | // return
12 | return
13 | Payment Details:
14 |
15 |
16 |
17 |
18 | { showDismiss ? 'Dismiss' : 'Cancel' }
20 |
21 | ;
22 | // ;
23 | };
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/ui/pages/LandingPage/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { LandingPageForm } from './landingPageForm';
3 | import { Container } from 'reactstrap';
4 | import { LargeTextDiv, LessLargeTextDiv } from '../../elements/textElements';
5 | import { e2eAssist } from '../../../testability';
6 |
7 |
8 | const LandingPage = () => {
9 |
10 | return
11 |
12 | FTGO Application
13 | Pick delivery address and time:
14 |
15 |
16 |
17 |
18 | ;
19 | };
20 |
21 | export default LandingPage;
22 |
23 |
--------------------------------------------------------------------------------
/src/ui/pages/LandingPage/landingPageForm.js:
--------------------------------------------------------------------------------
1 | import { useForm } from 'react-hook-form';
2 | import React, { useCallback } from 'react';
3 | import { Col, Form, FormFeedback, FormGroup } from 'reactstrap';
4 | import { InputWithIcon, RoundedButton } from '../../elements/formElements';
5 | import { IconClock, IconGeo, IconRefresh, IconSearch } from '../../elements/icons';
6 | import { useDispatch, useSelector } from 'react-redux';
7 | import {
8 | accessDeliveryAddress,
9 | accessDeliveryTime,
10 | retrieveRestaurantsForAddress
11 | } from '../../../features/address/addressSlice';
12 | import { If } from '../../elements/conditions';
13 | import { processFormSubmissionError } from '../../../shared/forms/submissionHandling';
14 | import { e2eAssist } from '../../../testability';
15 |
16 | export const LandingPageForm = () => {
17 |
18 | const deliveryAddress = useSelector(accessDeliveryAddress());
19 | const deliveryTime = useSelector(accessDeliveryTime());
20 |
21 | const { register, handleSubmit, setError, clearErrors, formState: { isSubmitting, errors } } = useForm({
22 | // a bug. In the presence of the below resolver, it is possible to resubmit after errors
23 | // however, the required fields validation is not taking place
24 | // TODO: resolve this dualistic condition
25 | resolver: (values, ctx, options) => {
26 | return ({
27 | values, errors: {}
28 | });
29 | },
30 | defaultValues: {
31 | address: deliveryAddress,
32 | time: deliveryTime
33 | }
34 | });
35 |
36 | const dispatch = useDispatch();
37 | const onSubmit = useCallback(async data => {
38 |
39 | const payload = await dispatch(retrieveRestaurantsForAddress({ ...data }));
40 |
41 | payload.error && processFormSubmissionError((payload.meta.rejectedWithValue ?
42 | payload.payload :
43 | payload.error), setError, clearErrors);
44 |
45 | }, [ setError, dispatch, clearErrors ]);
46 |
47 | return ;
80 | };
81 |
--------------------------------------------------------------------------------
/src/ui/pages/LandingPage/landingPageForm.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import '@testing-library/jest-dom/extend-expect';
3 | import { cleanup, render, wait } from '@testing-library/react';
4 | import { Provider } from 'react-redux';
5 | import { history, store } from '../../../app/store';
6 | import { ConnectedRouter } from 'connected-react-router';
7 | import { LandingPageForm } from './landingPageForm';
8 |
9 | describe(`src/ui/pages/LandingPage/landingPageForm.js`, () => {
10 |
11 | describe(`LandingPageForm component`, () => {
12 |
13 |
14 | afterEach(cleanup);
15 |
16 | test('renders landing page form', async () => {
17 | const { getByText } = render(
18 |
19 |
20 |
21 |
22 |
23 | );
24 |
25 | await wait();
26 |
27 | expect(getByText(/Search now/i)).toBeInTheDocument();
28 | });
29 |
30 |
31 | });
32 |
33 | });
34 |
--------------------------------------------------------------------------------
/src/ui/pages/LoginPage/index.js:
--------------------------------------------------------------------------------
1 | const LoginPage = () => {
2 | return <>LoginPage>;
3 | }
4 |
5 | export default LoginPage;
6 |
--------------------------------------------------------------------------------
/src/ui/pages/RestaurantListPage/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | accessDeliveryAddress,
3 | accessDeliveryTime,
4 | accessRestaurantsList,
5 | retrieveRestaurantsForAddress
6 | } from '../../../features/address/addressSlice';
7 | import { useDispatch, useSelector } from 'react-redux';
8 | import React, { useCallback, useEffect, useMemo } from 'react';
9 | import { navigateToEditMenu } from '../../../features/actions/navigation';
10 | import { Col, Container } from 'reactstrap';
11 | import { LessLargeTextDiv } from '../../elements/textElements';
12 | import 'react-bootstrap-table-next/dist/react-bootstrap-table2.min.css';
13 | import { keepSelectedRestaurant } from '../../../features/restaurants/restaurantsSlice';
14 | import { SelectedAddressRow } from '../../components/SelectedAddressRow';
15 | import { PaginatedTable } from '../../elements/paginatedTable';
16 | import { resetCart } from '../../../features/cart/cartSlice';
17 | import { e2eAssist } from '../../../testability';
18 |
19 | export const RestaurantListPage = () => {
20 |
21 | const dispatch = useDispatch();
22 | const deliveryAddress = useSelector(accessDeliveryAddress());
23 | const deliveryTime = useSelector(accessDeliveryTime());
24 | const restaurants = useSelector(accessRestaurantsList());
25 |
26 | useEffect(() => {
27 | if (restaurants) {
28 | return;
29 | }
30 | if (!deliveryAddress || !deliveryTime) {
31 | return;
32 | }
33 | dispatch(retrieveRestaurantsForAddress({ address: deliveryAddress, time: deliveryTime }));
34 | }, [ deliveryAddress, deliveryTime, dispatch, restaurants ]);
35 |
36 |
37 | const columns = [ {
38 | dataField: 'id',
39 | text: 'Ref ID',
40 | sort: true
41 | }, {
42 | dataField: 'name',
43 | text: 'Restaurant',
44 | sort: true
45 | }, {
46 | dataField: 'cuisine',
47 | text: 'Cuisine',
48 | sort: true
49 | }, {
50 | dataField: 'address',
51 | text: 'Address',
52 | sort: true
53 | } ];
54 |
55 | const defaultSorted = [ {
56 | dataField: 'name',
57 | order: 'desc'
58 | } ];
59 |
60 | const handleRowSelect = useCallback((entry) => {
61 | dispatch(keepSelectedRestaurant(entry));
62 | dispatch(resetCart());
63 | entry?.id && dispatch(navigateToEditMenu(entry.id));
64 | }, [ dispatch ]);
65 |
66 | const selectRow = useMemo(() => ({
67 | mode: 'radio',
68 | clickToSelect: true,
69 | selectionHeaderRenderer: () => null,
70 | selectionRenderer: ({ mode, ...rest }) => null,
71 | style: { backgroundColor: '#c8e6c980' },
72 | onSelect: handleRowSelect
73 | }), [ handleRowSelect ]);
74 |
75 | return
76 |
77 |
78 |
79 | Restaurants:
80 | Listing: { String(restaurants?.length ?? 0) }
81 |
82 |
83 | No restaurants> }
89 | columns={ columns }
90 | defaultSorted={ defaultSorted }
91 | selectRow={ selectRow }
92 | bordered={ false }
93 | paginationOnTop
94 | { ...e2eAssist.TBL_RESTAURANTS_LIST }
95 | paginationFactoryOptions={ {
96 | custom: true,
97 | sizePerPage: 5,
98 | sizePerPageList: [ 5, 10, 25, 30, 50 ],
99 | hidePageListOnlyOnePage: true
100 | } }
101 | />
102 |
103 |
104 |
105 |
;
106 | };
107 |
108 | export default RestaurantListPage;
109 |
110 |
--------------------------------------------------------------------------------
/src/ui/pages/RestaurantPage/hooks.js:
--------------------------------------------------------------------------------
1 | import { debugged } from '../../../shared/diagnostics';
2 | import { useDispatch } from 'react-redux';
3 | import { useMemo } from 'react';
4 | import curry from 'lodash-es/curry';
5 | import { updateCartWithItemAsyncThunk } from '../../../features/cart/cartSlice';
6 |
7 | /**
8 | *
9 | * @param orderId
10 | * @param cartItemsMap
11 | * @param selectedRestaurantId
12 | * @return {*|(function(*, *): function(*): (function(...[*]): (*|undefined))|*)}
13 | */
14 | export function useUpdateCartHandler(orderId, cartItemsMap, selectedRestaurantId) {
15 | const dummyHandler = (a, b) => (c) => debugged([ a, b, c ]);
16 |
17 | const dispatch = useDispatch();
18 | return useMemo(() => orderId ? curry((itemId, menuItem, cartItem, diff, _) => {
19 | const item = cartItem || { count: 0, name: menuItem?.name, price: menuItem?.price };
20 | const restaurantId = selectedRestaurantId ?? (item.meta?.restaurantId ?? null);
21 | if (typeof item.oldCount !== 'undefined') {
22 | return;
23 | }
24 | dispatch(updateCartWithItemAsyncThunk({
25 | restaurantId,
26 | itemId,
27 | qty: Math.max(0, item.count + diff),
28 | item
29 | }));
30 | }) : dummyHandler, [ orderId, selectedRestaurantId, dispatch ]);
31 | }
32 |
33 | export function createMap(arr, idGetter) {
34 | return new Map(arr.map(i => ([ idGetter(i), i ])));
35 | }
36 |
--------------------------------------------------------------------------------
/src/ui/pages/RestaurantPage/index.js:
--------------------------------------------------------------------------------
1 | import { SelectedAddressRow } from '../../components/SelectedAddressRow';
2 | import { Button, Col, Container } from 'reactstrap';
3 | import { SelectedRestaurantRow } from '../../components/SelectedRestaurantRow';
4 | import { useDispatch, useSelector } from 'react-redux';
5 | import { accessSelectedRestaurantId, resetSelectedRestaurant } from '../../../features/restaurants/restaurantsSlice';
6 | import React, { useCallback, useEffect } from 'react';
7 | import { navigateToCheckout, navigateToEditDeliveryAddress } from '../../../features/actions/navigation';
8 | import { YourTrayItems } from './yourTrayItems';
9 | import { MenuItems } from './menuItems';
10 | import { IconChevronRight } from '../../elements/icons';
11 | import { accessCartInfo, accessCartItems, accessVerboseCartInfo } from '../../../features/cart/cartSlice';
12 | import { e2eAssist } from '../../../testability';
13 | import { accessIsLoading } from '../../../features/ui/loadingSlice';
14 |
15 |
16 | export const RestaurantPage = ({ match }) => {
17 |
18 | const { placeId: urlRestaurantId } = match.params;
19 |
20 | const dispatch = useDispatch();
21 | const selectedRestaurantId = useSelector(accessSelectedRestaurantId());
22 | const cartItems = useSelector(accessCartItems());
23 | const isLoading = useSelector(accessIsLoading());
24 | const cartInfo = useSelector(accessCartInfo());
25 | const verboseCartInfo = useSelector(accessVerboseCartInfo());
26 |
27 | const handleToCheckout = useCallback(() => {
28 | dispatch(navigateToCheckout());
29 | }, [ dispatch ]);
30 |
31 | useEffect(() => {
32 | if (selectedRestaurantId && urlRestaurantId && (selectedRestaurantId === urlRestaurantId)) {
33 | return;
34 | }
35 | dispatch(resetSelectedRestaurant());
36 | dispatch(navigateToEditDeliveryAddress());
37 | }, [ dispatch, selectedRestaurantId, urlRestaurantId ]);
38 |
39 | return
40 |
41 |
42 |
43 |
44 | Menu Items:
45 |
46 |
47 |
48 | Your Tray: { verboseCartInfo.subTotal ?? '' }
49 |
50 |
51 |
52 | Checkout
53 |
54 |
55 |
56 |
;
57 | };
58 |
59 | export default RestaurantPage;
60 |
--------------------------------------------------------------------------------
/src/ui/pages/RestaurantPage/menuItems.js:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from 'react-redux';
2 | import { accessMenuForRestaurant, accessRestaurantMenuState } from '../../../features/restaurants/restaurantsSlice';
3 | import { accessCart, accessCartItems, obtainCartAsyncThunk } from '../../../features/cart/cartSlice';
4 | import React, { useCallback, useEffect, useMemo } from 'react';
5 | import { retrieveRestaurantByIdAsyncThunk } from '../../../features/address/addressSlice';
6 | import { createMap, useUpdateCartHandler } from './hooks';
7 | import { Button } from 'reactstrap';
8 | import { IconCartPlus, IconPlus } from '../../elements/icons';
9 | import { PaginatedTable } from '../../elements/paginatedTable';
10 | import { usePrevious } from 'react-use';
11 | import { e2eAssist } from '../../../testability';
12 |
13 |
14 | /**
15 | * @value {
16 | 'id': '224474',
17 | 'name': 'Chicken Livers and Portuguese Roll',
18 | 'position': 1,
19 | 'price': '250.00',
20 | 'consumable': '1:1',
21 | 'cuisine': 'Indian',
22 | 'category_name': 'Appeteasers',
23 | 'discount': {
24 | 'type': '',
25 | 'amount': '0.00'
26 | },
27 | 'tags': []
28 | }
29 | */
30 |
31 | /**
32 | *
33 | * @param restaurantId
34 | * @return {JSX.Element}
35 | * @constructor
36 | */
37 | export function MenuItems({ restaurantId }) {
38 |
39 | const dispatch = useDispatch();
40 | const menuState = useSelector(accessRestaurantMenuState(restaurantId));
41 | const emptyArr = useMemo(() => ([]), []);
42 | const menuList = useSelector(accessMenuForRestaurant(restaurantId, emptyArr));
43 | const cartItems = useSelector(accessCartItems());
44 | const cartItemsMap = useMemo(() => createMap(cartItems, i => i.id), [ cartItems ]);
45 | const dataSource = useMemo(() => menuList.map(item => cartItemsMap.has(item.id) ?
46 | Object.assign({ cart: cartItemsMap.get(item.id) }, item) :
47 | item), [ cartItemsMap, menuList ]);
48 | const orderId = useSelector(accessCart('orderId'));
49 |
50 | useEffect(() => {
51 | if (menuState) {
52 | return;
53 | }
54 | dispatch(retrieveRestaurantByIdAsyncThunk({ restaurantId }));
55 | }, [ dispatch, menuState, restaurantId ]);
56 |
57 | useEffect(() => {
58 | if (orderId) {
59 | return;
60 | }
61 | dispatch(obtainCartAsyncThunk());
62 | }, [ orderId, dispatch ]);
63 |
64 |
65 | const handleAddToCart = useUpdateCartHandler(orderId, cartItemsMap, restaurantId);
66 |
67 | const actionColumnFormatter = useCallback((cellContent, row, rowId, orderId) => {
68 | if (row.cart) {
69 | const cartItem = row.cart;
70 | return ;
72 | }
73 | return ;
75 | }, [ handleAddToCart ]);
76 |
77 | const columns = useMemo(() => ([
78 | {
79 | dataField: 'id',
80 | text: 'Ref ID',
81 | sort: true
82 | }, {
83 | dataField: 'name',
84 | text: 'Food Item',
85 | sort: true
86 | }, {
87 | dataField: 'category_name',
88 | text: 'Category',
89 | sort: true
90 | }, {
91 | dataField: 'price',
92 | text: 'Price',
93 | sort: true
94 | }, {
95 | dataField: 'actions',
96 | isDummyField: true,
97 | text: 'Add To Cart',
98 | formatter: actionColumnFormatter,
99 | formatExtraData: orderId,
100 | classes: 'text-right'
101 | }
102 | ]), [ actionColumnFormatter, orderId ]);
103 |
104 | const defaultSorted = [ {
105 | dataField: 'name',
106 | order: 'desc'
107 | } ];
108 |
109 | const prevcartId = usePrevious(orderId);
110 | console.log(prevcartId, ' => ', orderId);
111 |
112 | if (menuState !== 'ready') {
113 | return <>Updating the menu...>;
114 | }
115 |
116 | return Menu is temporarily empty }
122 | columns={ columns }
123 | defaultSorted={ defaultSorted }
124 | bordered={ false }
125 | paginationOnTop
126 | paginationFactoryOptions={ {
127 | custom: true,
128 | sizePerPage: 5,
129 | sizePerPageList: [ 5, 10, 25, 30, 50 ],
130 | hidePageListOnlyOnePage: true
131 | } }
132 | { ...e2eAssist.TBL_RESTAURANT_MENU }
133 | />;
134 |
135 | }
136 |
--------------------------------------------------------------------------------
/src/ui/pages/RestaurantPage/yourTrayItems.js:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { accessCart, accessCartItems, accessCartStatus } from '../../../features/cart/cartSlice';
3 | import { createMap, useUpdateCartHandler } from './hooks';
4 | import React, { useCallback, useMemo } from 'react';
5 | import { Button, ButtonGroup, Card, CardBody, CardTitle } from 'reactstrap';
6 | import { IconMinus, IconPlus, IconTrash } from '../../elements/icons';
7 | import { PaginatedTable } from '../../elements/paginatedTable';
8 | import { e2eAssist } from '../../../testability';
9 |
10 | export function YourTrayItems({ checkout }) {
11 |
12 | const orderId = useSelector(accessCart('orderId'));
13 | const cartStatus = useSelector(accessCartStatus());
14 | const cartItems = useSelector(accessCartItems());
15 | const cartItemsMap = useMemo(() => createMap(cartItems || [], i => i.id), [ cartItems ]);
16 | const handleAddToCart = useUpdateCartHandler(orderId, cartItemsMap, undefined);
17 |
18 | const actionColumnFormatter = useCallback((cellContent, row, rowIdx, orderId) => {
19 | const disabled = !orderId || (typeof row.oldCount !== 'undefined');
20 | return
21 |
22 | { row.count }
23 |
24 | ;
25 | }, [ handleAddToCart ]);
26 |
27 | const columns = useMemo(() => ([
28 | {
29 | dataField: 'name',
30 | text: 'Food Item',
31 | sort: !checkout
32 | },
33 | {
34 | dataField: 'actions',
35 | isDummyField: true,
36 | text: 'Quantity',
37 | sort: true,
38 | sortFunc: (a, b, order, dataField, rowA, rowB) => {
39 | if (order === 'asc') {
40 | return rowA.count - rowB.count;
41 | } else {
42 | return -(rowA.count - rowB.count);
43 | }
44 | },
45 | classes: 'text-right',
46 | formatter: actionColumnFormatter,
47 | formatExtraData: orderId
48 | }
49 | ]), [ actionColumnFormatter, orderId, checkout ]);
50 |
51 | const defaultSorted = [ {
52 | dataField: 'name',
53 | order: 'desc'
54 | } ];
55 |
56 | if (!orderId || (cartStatus !== 'ready')) {
57 | return <>Updating the tray...>;
58 | }
59 |
60 | if (checkout) {
61 | return cartItems.map((item, idx) => (
62 |
63 |
64 | { item.name }
65 | { actionColumnFormatter(null, item, idx, orderId) }
66 |
67 |
68 |
69 |
70 | ${ Number(item.price).toFixed(2) } × { Number(item.count) } = ${ (Number(item.price) * Number(item.count)).toFixed(2) }
71 |
72 |
73 |
74 | ));
75 | }
76 |
77 | return Add food to your tray }
83 | columns={ columns }
84 | defaultSorted={ defaultSorted }
85 | bordered={ false }
86 | paginationOnTop
87 | paginationFactoryOptions={ {
88 | custom: true,
89 | sizePerPage: 5,
90 | sizePerPageList: [ 5, 10, 25, 30, 50 ],
91 | hidePageListOnlyOnePage: true,
92 | } }
93 | { ...e2eAssist.TBL_YOUR_TRAY }
94 | />;
95 | }
96 |
--------------------------------------------------------------------------------
/src/ui/pages/ThankYouPage/index.js:
--------------------------------------------------------------------------------
1 | import { Container } from 'reactstrap';
2 | import { e2eAssist } from '../../../testability';
3 | import React, { useEffect } from 'react';
4 | import { useDispatch, useSelector } from 'react-redux';
5 | import { accessCart, accessVerboseCartInfo } from '../../../features/cart/cartSlice';
6 | import { navigateToEditDeliveryAddress } from '../../../features/actions/navigation';
7 |
8 | export const ThankYouPage = () => {
9 | const verboseCartInfo = useSelector(accessVerboseCartInfo());
10 | const orderId = useSelector(accessCart('orderId'));
11 | const dispatch = useDispatch();
12 |
13 | useEffect(() => {
14 | if (!orderId) {
15 | dispatch(navigateToEditDeliveryAddress());
16 | }
17 | }, [ dispatch, orderId ]);
18 |
19 | return
20 |
21 | Thank You For Placing Your Order!
22 | Your order
#{ orderId }
for the amount of
{ verboseCartInfo.total }
has been placed and is being processed.
23 |
24 |
25 |
;
26 | };
27 |
28 | export default ThankYouPage;
29 |
--------------------------------------------------------------------------------
/src/window.setup.js:
--------------------------------------------------------------------------------
1 | console.log('src/window.setup.js');
2 |
3 | window.scrollTo = jest.fn();
4 | window.prompt = jest.fn();
5 | window.alert = jest.fn();
6 |
--------------------------------------------------------------------------------
/start-http-server.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 |
3 | docker-compose up -d --build
4 | sleep 10
5 | docker-compose logs
6 | docker-compose ps
7 | #docker-compose exec web /bin/bash
8 |
--------------------------------------------------------------------------------
/test-docker-image.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 |
3 | ./start-http-server.sh
4 |
5 | ./wait-for-services.sh
6 |
--------------------------------------------------------------------------------
/tests-ui/browserSetup.js:
--------------------------------------------------------------------------------
1 | import puppeteer from 'puppeteer';
2 | import { InflightRequests, safeJSONValue } from './puppeteerExtensions';
3 | import { safelyExecuteAsync } from '../src/shared/promises';
4 | import { initializeNavigationContext } from './navigation';
5 |
6 | export function setUpBrowserAndPage(testLifecycleHooks, context, viewport, setPage) {
7 |
8 | const { beforeAll, afterAll } = testLifecycleHooks;
9 | if (!beforeAll || !afterAll) {
10 | throw new Error('Essential test lifecycle hooks are not specified ("beforeAll", "afterAll")');
11 | }
12 |
13 | const { width, height } = viewport;
14 |
15 | let browser = null;
16 | let page = null;
17 |
18 | beforeAll(async () => {
19 |
20 | let launchErr;
21 | ([ launchErr, browser ] = await safelyExecuteAsync(puppeteer.launch({
22 | // devtools: true,
23 | timeout: 0,
24 | headless: false,
25 | ignoreHTTPSErrors: true,
26 | slowMo: 2,
27 | args: [
28 | `--window-size=${ width },${ height }`,
29 | // FTGO_ENV: dev - local machine
30 | process.env.FTGO_ENV === 'dev' ? '' : '--no-sandbox',
31 | '--disable-dev-shm-usage',
32 | '--disable-setuid-sandbox',
33 | '--disable-features=site-per-process',
34 | '--disable-accelerated-2d-canvas',
35 | '--disable-gpu',
36 | // '--disable-web-security',
37 | // '--user-data-dir=~/.'
38 | ]
39 | })));
40 | if (launchErr || !browser) {
41 | throw new Error(`[ftgo-consumer-web-ui/tests-ui/browserSetup]: Puppeteer failed to produce a new instance of a browser when 'puppeteer.launch(..)'. Error: ${ launchErr?.message }`);
42 | }
43 |
44 | console.log(`Using Puppeteer Chromium version:`, await browser.version());
45 |
46 | // browser.on('disconnected', () => {
47 | // try {
48 | // console.log('Browser disconnected.');
49 | // } catch (_) {}
50 | // });
51 |
52 | browser.on('targetdestroyed', (e) => {
53 | try {
54 | console.log('Browser target destroyed event. Event:', e, ' Stack trace:', (new Error()).stack);
55 | } catch (_) {}
56 | });
57 |
58 | let pageErr;
59 | ([ pageErr, page ] = await safelyExecuteAsync(browser.newPage()));
60 | if (pageErr) {
61 | throw new Error(`[ftgo-consumer-web-ui/tests-ui/browserSetup]: Puppeteer failed to create a new page when 'browser.newPage()'. Error: ${ pageErr?.message }`);
62 | }
63 |
64 | Object.assign(context, { page });
65 |
66 | await page.setRequestInterception(true);
67 | page.on('request', request => request.continue());
68 | const requestsTracker = new InflightRequests(page);
69 |
70 | initializeNavigationContext({ requestsTracker });
71 |
72 | Object.assign(context, { requestsTracker });
73 |
74 | setPage && setPage(page);
75 |
76 | const consoleMsgs = [];
77 | Object.assign(context, { consoleMsgs });
78 |
79 | page.on('console', async ({ _type, _text, _args }) => {
80 | context.consoleMsgs.push([
81 | _type.padEnd(8), _text, ...(await Promise.all(_args.map(arg => safeJSONValue(arg)))).map(JSON.stringify)
82 | ]);
83 | });
84 |
85 | await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36');
86 | await page.setViewport(viewport); // width, height
87 |
88 | });
89 |
90 | afterAll(() => {
91 | if (context.requestsTracker) {
92 | context.requestsTracker.dispose();
93 | }
94 | browser && browser.close();
95 | });
96 |
97 | return page;
98 | }
99 |
--------------------------------------------------------------------------------
/tests-ui/comprehensive.spec.js:
--------------------------------------------------------------------------------
1 | import notifier from './reporters/defaultNotifier';
2 | import { DEFAULT_TIMEOUT } from './jest.setup';
3 | import { makeScreenshot, testWrap } from './testWrapper';
4 | import { ensureLocalizedTimeString, obtainTestInfo } from './testInfoProvider';
5 | import { ensureEnvVariable } from '../src/shared/env';
6 | import { waitForTimeout } from './puppeteerExtensions';
7 | import { setUpBrowserAndPage } from './browserSetup';
8 | import { navigation } from './pages/navigation';
9 | import { landingPage } from './pages/landing';
10 | import { restaurantsListPage } from './pages/restaurantsList';
11 | import { restaurantMenuPage } from './pages/restaurantMenu';
12 | import { checkoutPage } from './pages/checkout';
13 | import { summarizePageObject } from './pages/utilities';
14 | import { thankYouPage } from './pages/thankYou';
15 |
16 | void makeScreenshot;
17 |
18 | const ctx = {
19 | page: null,
20 | store: new Map(),
21 | testInfo: null
22 | };
23 |
24 | const TIMEOUT = DEFAULT_TIMEOUT;
25 | const test$ = testWrap(global.test, ctx, TIMEOUT);
26 | const [ describe, xdescribe, test, xtest ] = ((d, xd) => ([
27 | d,
28 | process.env.NODE_ENV === 'test' ? xd : d,
29 | test$,
30 | process.env.NODE_ENV === 'test' ? global.xtest : test$
31 | ]))(global.describe, global.xdescribe);
32 |
33 | void xtest;
34 | void xdescribe;
35 |
36 | const [ width, height ] = ensureEnvVariable('TEST_UI_DIMENSIONS', '1200x800').split('x').map(Number);
37 |
38 | let page = setUpBrowserAndPage({ beforeAll, afterAll }, ctx, { width, height }, p => {
39 | ctx.page = page = p;
40 | // TODO: The default value can be changed by using the page.setDefaultNavigationTimeout(timeout) or
41 | // page.setDefaultTimeout(timeout) methods. NOTE page.setDefaultNavigationTimeout takes priority over
42 | // page.setDefaultTimeout
43 | });
44 |
45 | const testInfo = ctx.testInfo = obtainTestInfo();
46 |
47 |
48 | console.log('NODE_ENV: ', process.env.NODE_ENV, ensureEnvVariable('TEST_UI_URL'), testInfo.email, testInfo.password, `(${ testInfo.newPassword })`);
49 |
50 |
51 | // https://www.valentinog.com/blog/ui-testing-jest-puppetteer/
52 |
53 |
54 | async function warnSpectator(page) {
55 | await notifier.notify('The interesting part starts in less than 5 seconds');
56 | await page.waitForTimeout(5000);
57 | }
58 |
59 | void warnSpectator;
60 |
61 | describe('Interaction with the entire FTGO UI application:', () => {
62 |
63 | beforeAll(async () => {
64 | await ensureLocalizedTimeString(page, testInfo);
65 | });
66 |
67 | afterAll(async () => {
68 | await waitForTimeout(page, 1000);
69 | });
70 |
71 | afterAll(() => {
72 | summarizePageObject(true);
73 | });
74 |
75 | describe('00. Ground-zero tests. Browser capabilities', () => {
76 |
77 | test(`Settings`, () => {
78 |
79 | console.log('NODE_ENV: ', process.env.NODE_ENV);
80 | console.log(ensureEnvVariable('TEST_UI_URL'), testInfo.email);
81 | console.log('[testInfo.goodAddress.time]', testInfo.goodAddress.time);
82 |
83 | });
84 |
85 | test(`Navigation to Landing and a screenshot`, async () => {
86 |
87 | await navigation(page).visitTheSite();
88 | await landingPage(page).expectVisitingSelf();
89 |
90 | await makeScreenshot(page, { label: 'intro' });
91 |
92 | });
93 | });
94 |
95 | describe(`[Landing Page] -> Restaurants List -> Menu Page`, () => {
96 |
97 | test(`Navigation to Landing`, async () => {
98 | await navigation(page).visitTheSite();
99 | await landingPage(page).expectVisitingSelf();
100 | });
101 |
102 | test(`[landing page] Correct entry, submission, landing on Restaurants List`, async () => {
103 |
104 | await landingPage(page).fillOutTheAddressAndTimeForm(testInfo.goodAddress);
105 | await landingPage(page).submitTheAddressAndTimeFormSuccessfully();
106 | await restaurantsListPage(page).expectVisitingSelf();
107 |
108 | });
109 |
110 | test(`[restaurants list page] Navigation, picking correct restaurant, landing of Menu page`, async () => {
111 |
112 | await restaurantsListPage(page).browseForRestaurantWithMenuItems();
113 | await restaurantMenuPage(page).expectVisitingSelf();
114 |
115 | });
116 |
117 | test(`[restaurant menu page] Structure check, menu picking, going to checkout`, async () => {
118 | await restaurantMenuPage(page).checkStructure();
119 | await restaurantMenuPage(page).putMenuItemIntoACart();
120 | await restaurantMenuPage(page).proceedToCheckout();
121 |
122 | await checkoutPage(page).expectVisitingSelf();
123 | });
124 |
125 | describe(`[checkout page]`, () => {
126 |
127 | test(`[required elements before payment]`, async () => {
128 | await checkoutPage(page, expect).expectCartNotEmptyAndReadyToPay();
129 | });
130 |
131 | test(`[payment modal interaction]`, async () => {
132 | await checkoutPage(page, expect).playWithThePaymentModal();
133 | });
134 |
135 | test(`[payment form interaction]`, async () => {
136 | await checkoutPage(page, expect).playWithThePaymentFormRequireds();
137 | });
138 |
139 | test(`[payment form] declined payment`, async () => {
140 |
141 | await checkoutPage(page, expect).attemptDeclinedCard();
142 |
143 | });
144 |
145 | test(`[payment form] accepted payment`, async () => {
146 |
147 | await checkoutPage(page, expect).attemptValidOkCard();
148 |
149 | });
150 |
151 | describe(`[thank you page]`, () => {
152 |
153 | test(`[thank you page] successful navigation`, async () => {
154 |
155 | await thankYouPage(page, expect).expectVisitingSelf();
156 |
157 | });
158 |
159 | test(`[thank you page] order ID is present`, async () => {
160 |
161 | await thankYouPage(page, expect).ensureOrderIdIsPresent();
162 |
163 | });
164 | })
165 |
166 | });
167 |
168 | });
169 |
170 | });
171 |
--------------------------------------------------------------------------------
/tests-ui/ensure_env.js:
--------------------------------------------------------------------------------
1 | import 'react-scripts/config/env.js';
2 |
3 |
--------------------------------------------------------------------------------
/tests-ui/envLoader.js:
--------------------------------------------------------------------------------
1 | console.log('envLoader');
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 |
6 | const NODE_ENV = process.env.NODE_ENV;
7 | if (!NODE_ENV) {
8 | throw new Error(
9 | 'The NODE_ENV environment variable is required but was not specified.'
10 | );
11 | }
12 |
13 | const SKIP_ENV_LOAD = process.env.SKIP_ENV_LOAD;
14 | if (SKIP_ENV_LOAD) {
15 | return;
16 | }
17 |
18 | const appDirectory = fs.realpathSync(process.cwd());
19 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
20 | const dotenvPath = resolveApp('.env');
21 |
22 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
23 | const dotenvFiles = [
24 | `${ dotenvPath }.${ NODE_ENV }.local`,
25 | `${ dotenvPath }.${ NODE_ENV }`,
26 | // Don't include `.env.local` for `test` environment
27 | // since normally you expect tests to produce the same
28 | // results for everyone
29 | NODE_ENV !== 'test' && `${ dotenvPath }.local`,
30 | dotenvPath,
31 | ].filter(Boolean);
32 |
33 | // Load environment variables from .env* files. Suppress warnings using silent
34 | // if this file is missing. dotenv will never modify any environment variables
35 | // that have already been set. Variable expansion is supported in .env files.
36 | // https://github.com/motdotla/dotenv
37 | // https://github.com/motdotla/dotenv-expand
38 | dotenvFiles.forEach(dotenvFile => {
39 | console.log('Attempting to load: ', dotenvFile);
40 | if (fs.existsSync(dotenvFile)) {
41 | console.log('Loading: ', dotenvFile);
42 | require('dotenv-expand')(
43 | require('dotenv').config({
44 | path: dotenvFile,
45 | })
46 | );
47 | }
48 | });
49 |
50 | // We support resolving modules according to `NODE_PATH`.
51 | // This lets you use absolute paths in imports inside large monorepos:
52 | // https://github.com/facebook/create-react-app/issues/253.
53 | // It works similar to `NODE_PATH` in Node itself:
54 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
55 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
56 | // Otherwise, we risk importing Node.js core modules into an app instead of webpack shims.
57 | // https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
58 | // We also resolve them to make sure all tools using them work consistently.
59 | process.env.NODE_PATH = (process.env.NODE_PATH || '')
60 | .split(path.delimiter)
61 | .filter(folder => folder && !path.isAbsolute(folder))
62 | .map(folder => path.resolve(appDirectory, folder))
63 | .join(path.delimiter);
64 |
--------------------------------------------------------------------------------
/tests-ui/helpers/index.js:
--------------------------------------------------------------------------------
1 | //textField(page, SEL.FORM_FIELD_ADDRESS).enter(testData.address)
2 |
3 | //await waitClickAndType(page, SEL.FORM_FIELD_ADDRESS, testData.address);
4 | //await waitForTimeout(page, 10);
5 | import {
6 | readElementsText,
7 | waitClickAndType,
8 | waitForSelector,
9 | waitForSelectorAndClick,
10 | waitForSelectorNotPresent,
11 | waitForTimeout
12 | } from '../puppeteerExtensions';
13 | import { cssSel } from '../../src/shared/e2e';
14 | import { safelyExecuteAsync } from '../../src/shared/promises';
15 |
16 | export const textField = (page, sel) => {
17 | return {
18 | enter(text, replace) {
19 | return waitClickAndType(page, sel, text, replace);
20 | },
21 | ensurePresent() {
22 | return waitForSelector(page, sel);
23 | },
24 | expectInvalid() {
25 | return waitForSelector(page, cssSel(sel).attr('data-testid', '|invalid|', '*'));
26 | },
27 | expectNotInvalid() {
28 | return waitForSelector(page, cssSel(sel).not(cssSel('').attr('data-testid', '|invalid|', '*')));
29 | }
30 | };
31 | };
32 |
33 | export const element = (page, sel) => {
34 | return {
35 | ensurePresent(options) {
36 | return waitForSelector(page, sel, options);
37 | },
38 | expectAbsent() {
39 | return waitForSelectorNotPresent(page, sel);
40 | },
41 | async click() {
42 | await waitForSelectorAndClick(page, sel);
43 | return waitForTimeout(page, 10);
44 | },
45 | async expectDisabled(options) {
46 | return waitForSelector(page, cssSel(sel).mod('[disabled]'), options);
47 | },
48 | expectNotDisabled() {
49 | return waitForSelector(page, cssSel(sel).mod(':not([disabled])'));
50 | },
51 | async count() {
52 | console.log(`[Element.count]for selector: "${ String(sel) }"`);
53 | return (await page.$$(String(sel))).length;
54 | },
55 | has(childSel) {
56 | return waitForSelector(page, cssSel(sel).desc(childSel));
57 | },
58 | safelyGetText() {
59 | return safelyExecuteAsync(readElementsText(page, sel));
60 | }
61 | };
62 | };
63 |
--------------------------------------------------------------------------------
/tests-ui/jest.config.js:
--------------------------------------------------------------------------------
1 | console.log('jest.config.js');
2 | require('./envLoader');
3 | module.exports = {
4 | verbose: true,
5 | setupFilesAfterEnv: [ 'jest-extended', './jest.setup.js' ],
6 | // transformIgnorePatterns: [ 'node_modules/(?!shared-package)' ],
7 | 'reporters': [
8 | 'default',
9 | [ '/reporters/defaultReporter.js', {
10 | 'jest.config': 'tests-ui/jest.config.js'
11 | } ]
12 | ]
13 | };
14 |
--------------------------------------------------------------------------------
/tests-ui/jest.dev.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "--source": "source: https://jestjs.io/docs/en/troubleshooting#debugging-in-vs-code",
4 | "configurations": [
5 | {
6 | "name": "Debug CRA Tests",
7 | "type": "node",
8 | "request": "launch",
9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/react-scripts",
10 | "args": ["test", "--runInBand", "--no-cache", "--env=jsdom"],
11 | "cwd": "${workspaceRoot}",
12 | "protocol": "inspector",
13 | "console": "integratedTerminal",
14 | "internalConsoleOptions": "neverOpen",
15 | "reporters": [
16 | "default",
17 | ["/reporters/defaultReporter.js", {"banana": "yes", "pineapple": "no", "json": 6 }]
18 | ]
19 | }
20 | ]
21 | }
--------------------------------------------------------------------------------
/tests-ui/jest.setup.js:
--------------------------------------------------------------------------------
1 | export const DEFAULT_TIMEOUT = 30000;
2 | jest.setTimeout(DEFAULT_TIMEOUT);
3 | console.log('jest.setup.js');
4 |
--------------------------------------------------------------------------------
/tests-ui/navigation.js:
--------------------------------------------------------------------------------
1 | import { safelyExecuteAsync } from '../src/shared/promises';
2 | import { ensureEnvVariable } from '../src/shared/env';
3 | import { injectHelperScripts, waitForPathnameLocation, waitForSelector } from './puppeteerExtensions';
4 | import { SEL } from './selectors';
5 |
6 | const throwIfUndefined = (arg) => {
7 | if (arg === undefined) {
8 | throw new Error();
9 | }
10 | return arg;
11 | }
12 | export const urlPatterns = {
13 | landing: /^\/start$/i,
14 | };
15 |
16 | export const appPaths = {
17 | loginPage: '/login',
18 | landing: '/apps',
19 | emptyResetPage: '/reset'
20 | };
21 |
22 | export const APP_URL = ensureEnvVariable('TEST_UI_URL');
23 |
24 | const navContext = {
25 | requestsTracker: undefined
26 | };
27 |
28 | export function initializeNavigationContext(props) {
29 | Object.assign(navContext, props);
30 | }
31 |
32 | /**
33 | *
34 | * @param page Puppeteer page handle
35 | * @param relativePath - address to navigate to within APP
36 | * @returns {Promise}
37 | */
38 | export async function navigateToWithinApp(page, relativePath = '') {
39 | const url = removeDoubledSlashes(APP_URL + relativePath);
40 | console.log(`Navigating to URL: ${ url }`);
41 | const [ errNWI0 ] = await safelyExecuteAsync(page.goto(url, { waitUntil: 'networkidle0' }));
42 | if (errNWI0) {
43 |
44 | const inflight = navContext?.requestsTracker.inflightRequests() || [];
45 | console.log(inflight.map(request => ' ' + request.url()).join('\n'));
46 |
47 | const [ errNWI2 ] = await safelyExecuteAsync(page.goto(url, { waitUntil: 'networkidle2' }));
48 | if (errNWI2) {
49 | throw new Error(`Navigation to URL '${ url }' timed out. There are more than 2 open connections.`);
50 | }
51 | console.warn(`Navigation to URL '${ url }' has still not more than 2 open connections.`);
52 | } else {
53 | console.log(`Navigation to URL '${ url }' - Success`);
54 | }
55 | await injectHelperScripts(page);
56 | }
57 |
58 | export async function navigationToMissingApp(page, context) {
59 | const path = throwIfUndefined(context.store.get('sampleAppIdUrl')).replace(context.store.get('sampleAppId'), 'NONEXISTENT_APP');
60 | await navigateToMissingEntitySteps(page, path, urlPatterns.landing);
61 | }
62 |
63 | export async function navigateToMissingServiceWithinMissingApp(page, context) {
64 | const path = `${ throwIfUndefined(context.store.get('sampleAppIdUrl')).replace(context.store.get('sampleAppId'), 'NONEXISTENT_APP') }/service/NONEXISTENT_SVC`;
65 | await navigateToMissingEntitySteps(page, path, urlPatterns.landing);
66 | }
67 |
68 | export async function navigateToMissingServiceWithinExistingApp(page, context) {
69 | const path = `${ throwIfUndefined(context.store.get('sampleAppIdUrl')) }/service/NONEXISTENT_SVC`;
70 | await navigateToMissingEntitySteps(page, path, urlPatterns.appItemPage);
71 | }
72 |
73 | async function navigateToMissingEntitySteps(page, path, urlPattern) {
74 | await navigateToWithinApp(page, path);
75 | await Promise.all([
76 | waitForSelector(page, SEL.ALERT_INFO),
77 | waitForSelector(page, SEL.ALERT_DANGER)
78 | ]);
79 | await waitForPathnameLocation(page, urlPattern);
80 | }
81 |
82 |
83 | function removeDoubledSlashes(input) {
84 | return input.replace('://', '_PROTO_SEP_').replace('//', '/').replace('_PROTO_SEP_', '://');
85 | }
86 |
--------------------------------------------------------------------------------
/tests-ui/pages/landing.js:
--------------------------------------------------------------------------------
1 | import { waitForSelector, waitForTimeout } from '../puppeteerExtensions';
2 | import { SEL } from '../selectors';
3 | import { makeScreenshot } from '../testWrapper';
4 | import { tagPageObject } from './utilities';
5 | import {
6 | addressAndTimeForm,
7 | addressAndTimeFormSubmitButton,
8 | addressField,
9 | spinIcon,
10 | timeField
11 | } from './pageComponents';
12 | import { safelyExecuteAsync } from '../../src/shared/promises';
13 |
14 | export const landingPage = page => tagPageObject('landingPage', {
15 |
16 | expectVisitingSelf: () => waitForSelector(page, SEL.PAGE_LANDING),
17 |
18 | fillOutTheAddressAndTimeForm: async (testData) => {
19 | await addressAndTimeForm(page).ensurePresent();
20 | await addressField(page).enter(testData.address);
21 | await timeField(page).enter(testData.time);
22 |
23 | await makeScreenshot(page, { label: 'time_entry' });
24 | },
25 |
26 | submitTheAddressAndTimeFormSuccessfully: async () => {
27 | await addressAndTimeFormSubmitButton(page).click();
28 | await addressAndTimeFormSubmitButton(page).expectDisabled();
29 | const [ err ] = await safelyExecuteAsync(spinIcon(page).ensurePresent({ timeout: 5000 }));
30 |
31 | err && console.warn('[submitTheAddressAndTimeFormSuccessfully] Spinner was absent')
32 |
33 | await waitForTimeout(page, 300);
34 | }
35 |
36 | });
37 |
--------------------------------------------------------------------------------
/tests-ui/pages/navigation.js:
--------------------------------------------------------------------------------
1 | import { navigateToWithinApp } from '../navigation';
2 | import { waitForTimeout } from '../puppeteerExtensions';
3 | import { tagPageObject } from './utilities';
4 |
5 | export const navigation = page => tagPageObject('navigation', {
6 |
7 | visitTheSite: async () => {
8 | await navigateToWithinApp(page, '/');
9 | await waitForTimeout(page, 1000);
10 | }
11 |
12 | });
13 |
--------------------------------------------------------------------------------
/tests-ui/pages/pageComponents.js:
--------------------------------------------------------------------------------
1 | //await addressField(page).enter(testData.address);
2 |
3 | import { element, textField } from '../helpers';
4 | import { SEL } from '../selectors';
5 |
6 | export const addressField = page => textField(page, SEL.FORM_FIELD_ADDRESS);
7 | export const timeField = page => textField(page, SEL.FORM_FIELD_TIME);
8 | export const addressAndTimeForm = page => element(page, SEL.FORM_PICK_ADDRESS_TIME)
9 | export const addressAndTimeFormSubmitButton = page => element(page, SEL.BTN_SUBMIT_FORM_PICK_ADDRESS_TIME);
10 | export const spinIcon = page => element(page, SEL.ICON_SPIN);
11 |
--------------------------------------------------------------------------------
/tests-ui/pages/restaurantMenu.js:
--------------------------------------------------------------------------------
1 | import {
2 | waitForSelector,
3 | waitForSelectorAndClick,
4 | waitForSelectorNotPresent,
5 | waitForTimeout
6 | } from '../puppeteerExtensions';
7 | import { SEL } from '../selectors';
8 | import { tagPageObject } from './utilities';
9 | import { cssSel } from '../../src/shared/e2e';
10 | import { element } from '../helpers';
11 |
12 | const restaurantMenuTable = page => element(page, SEL.TBL_RESTAURANT_MENU);
13 | const yourTrayTable = page => element(page, SEL.TBL_YOUR_TRAY);
14 | const toCheckoutButton = page => element(page, SEL.BTN_TO_CHECKOUT);
15 | const addToCartButton = page => element(page, SEL.BTN_ADD_TO_CART);
16 |
17 | export const restaurantMenuPage = page => tagPageObject('restaurantMenuPage', {
18 |
19 | expectVisitingSelf: () => waitForSelector(page, SEL.PAGE_RESTAURANT_MENU),
20 |
21 | checkStructure: async () => {
22 | await restaurantMenuTable(page).ensurePresent();
23 | await yourTrayTable(page).ensurePresent();
24 | await toCheckoutButton(page).ensurePresent();
25 | },
26 |
27 | putMenuItemIntoACart: async () => {
28 | await toCheckoutButton(page).expectDisabled();
29 |
30 | await waitForSelector(page, cssSel(SEL.INFO_TRAY_IS_EMPTY));
31 | await waitForSelector(page, cssSel(SEL.INFO_CART_VALUE_OF(0)));
32 |
33 | const paginationControlSel = cssSel(SEL.TBL_RESTAURANT_MENU).desc(SEL.CTL_PAGINATION_FOR_TABLE);
34 | await waitForSelector(page, paginationControlSel);
35 | await waitForSelector(page,
36 | paginationControlSel.desc('.page-item').attr('title', 'next page').child('a.page-link'));
37 |
38 | await waitForSelectorNotPresent(page, SEL.BTN_ADD_TO_CART_ADDED);
39 | await addToCartButton(page).ensurePresent();
40 | await waitForSelector(page, SEL.BTN_ADD_TO_CART_FRESH);
41 |
42 | await waitForSelectorAndClick(page, SEL.BTN_ADD_TO_CART_FRESH);
43 | await addToCartButton(page).expectDisabled();
44 |
45 |
46 | await waitForTimeout(page, 1000);
47 | await waitForSelector(page, SEL.BTN_ADD_TO_CART_ADDED);
48 |
49 | await toCheckoutButton(page).expectNotDisabled();
50 |
51 | await waitForSelectorNotPresent(page, cssSel(SEL.INFO_CART_VALUE_OF(0)));
52 |
53 | },
54 |
55 | proceedToCheckout: async () => {
56 | await toCheckoutButton(page).expectNotDisabled();
57 | await toCheckoutButton(page).click();
58 | }
59 | });
60 |
--------------------------------------------------------------------------------
/tests-ui/pages/restaurantsList.js:
--------------------------------------------------------------------------------
1 | import { waitForSelector, waitForSelectorAndClick, waitForSelectorWithText } from '../puppeteerExtensions';
2 | import { SEL } from '../selectors';
3 | import { tagPageObject } from './utilities';
4 | import { cssSel } from '../../src/shared/e2e';
5 | import { element } from '../helpers';
6 |
7 | const tableRestaurantsList = page => element(page, SEL.PAGE_RESTAURANTS_LIST);
8 |
9 | export const restaurantsListPage = page => tagPageObject('restaurantsListPage', {
10 |
11 | expectVisitingSelf: () => tableRestaurantsList(page).ensurePresent(),
12 |
13 | browseForRestaurantWithMenuItems: async () => {
14 |
15 | await tableRestaurantsList(page).ensurePresent();
16 |
17 | const paginationControlSel = cssSel(SEL.TBL_RESTAURANTS_LIST).desc(SEL.CTL_PAGINATION_FOR_TABLE);
18 | await waitForSelector(page, paginationControlSel);
19 | await waitForSelectorAndClick(page,
20 | paginationControlSel.desc('.page-item').attr('title', 'next page').child('a.page-link'));
21 |
22 | const el = await waitForSelectorWithText(page, 'td', 'All items');
23 | await el.click();
24 |
25 | },
26 |
27 | });
28 |
--------------------------------------------------------------------------------
/tests-ui/pages/thankYou.js:
--------------------------------------------------------------------------------
1 | import { tagPageObject } from './utilities';
2 | import { element } from '../helpers';
3 | import { SEL } from '../../src/testability';
4 |
5 | const thankYouPageEl = page => element(page, SEL.PAGE_THANKYOU);
6 | const textOrderId = page => element(page, SEL.TEXT_ORDER_ID);
7 |
8 | export const thankYouPage = (page, expect) => tagPageObject('thankYouPage', {
9 | expectVisitingSelf: () => thankYouPageEl(page).ensurePresent(),
10 | ensureOrderIdIsPresent: async () => {
11 |
12 | const [ err, orderIdText ] = await textOrderId(page).safelyGetText();
13 | expect(err).toBeNull();
14 | expect(orderIdText.replace('#', '')).toBeTruthy();
15 | }
16 | });
17 |
--------------------------------------------------------------------------------
/tests-ui/pages/utilities.js:
--------------------------------------------------------------------------------
1 | const consoleOverload = [ 'log', 'group', 'groupEnd', 'warn', 'error', 'info', 'debug' ].reduce((memo, method) =>
2 | Object.assign(memo, {
3 | [ method ]: function (...args) {
4 | console[ method ] && console[ method ](...args);
5 | if (this._messages) {
6 | this._messages.push({
7 | timestamp: new Date().getTime(),
8 | method,
9 | args
10 | });
11 | }
12 | }
13 | }), {
14 | _messages: [],
15 | flush() {
16 | if (this._messages) {
17 | const result = Array.from(this._messages);
18 | this._messages = [];
19 | return result;
20 | }
21 | },
22 | restore() {
23 | return console;
24 | }
25 | });
26 |
27 | /**
28 | *
29 | * @param { string } name
30 | * @param { Object} obj
31 | * @return { Object }
32 | */
33 | export function tagPageObject(name, obj) {
34 | return tagAllFunctionalPairs(obj, createLoggedControlledExecution(consoleOverload), name);
35 | }
36 |
37 | /**
38 | *
39 | * @param { Object } object
40 | * @param {function} cb
41 | * @param extraArgs
42 | * @return { Object }
43 | */
44 | function tagAllFunctionalPairs(object, cb, ...extraArgs) {
45 | return Object.fromEntries(Array.from(Object.entries(object),
46 | ([ key, value ]) => [ key, (...args) => cb(key, value, args, ...extraArgs) ]));
47 | }
48 |
49 | export function summarizePageObject(useOriginalConsole) {
50 | if (!('flush' in consoleOverload)) {
51 | return;
52 | }
53 | const messages = consoleOverload.flush();
54 | if (useOriginalConsole) {
55 | messages.forEach(({ method, args }) => console[ method ](...args));
56 | } else {
57 | console.log(messages);
58 | }
59 | }
60 |
61 | function createLoggedControlledExecution(console) {
62 |
63 | /**
64 | *
65 | * @param key
66 | * @param fn
67 | * @param args
68 | * @param name
69 | * @return {*}
70 | */
71 | return function controlledExecution(key, fn, args, name = 'Unmarked object') {
72 | if (typeof fn !== 'function') {
73 | return fn;
74 | }
75 | const executionName = [ name, key ].filter(Boolean).map(camelCaseToSentenceCase).join(': ');
76 | console.group && console.group();
77 | console.log(`[${ executionName }] Executing`);
78 | try {
79 | const result = fn(...args);
80 | if (result instanceof Promise) {
81 | result.then(() => {
82 | console.log(`[${ executionName }] Resolved`);
83 | console.groupEnd && console.groupEnd();
84 | }, () => {
85 | console.log(`[${ executionName }] Rejected`);
86 | console.groupEnd && console.groupEnd();
87 | });
88 | } else {
89 | console.log(`[${ executionName }] Finished`);
90 | console.groupEnd && console.groupEnd();
91 | }
92 | return result;
93 | } catch (ex) {
94 | console.log(`[${ executionName }] Threw`);
95 | console.groupEnd && console.groupEnd();
96 | throw ex;
97 | }
98 | };
99 | }
100 |
101 | /**
102 | *
103 | * @param {string} text
104 | * @return {string}
105 | */
106 | function camelCaseToSentenceCase(text) {
107 | const result = text.replace(/([A-Z])/g, ' $1');
108 | return result.charAt(0).toUpperCase() + result.slice(1);
109 | }
110 |
--------------------------------------------------------------------------------
/tests-ui/reporters/.gitignore:
--------------------------------------------------------------------------------
1 | *.local.js
2 |
--------------------------------------------------------------------------------
/tests-ui/reporters/defaultNotifier.js:
--------------------------------------------------------------------------------
1 | const { extendWithLocal } = require('./utilities');
2 |
3 | class DefaultNotifier {
4 | notify() {}
5 | }
6 |
7 | const Notifier = extendWithLocal(DefaultNotifier, './localizedNotifier.local.js');
8 |
9 | module.exports = new Notifier();
10 |
--------------------------------------------------------------------------------
/tests-ui/reporters/defaultReporter.js:
--------------------------------------------------------------------------------
1 | const { extendWithLocal } = require('./utilities');
2 |
3 | console.log('hooksDefaultReporter.js');
4 |
5 | class HooksDefaultReporter {
6 | constructor(globalConfig, options) {
7 | this._globalConfig = globalConfig;
8 | this._options = options;
9 | }
10 | onRunStart(results, options) {
11 | console.debug('HooksDefaultReporter: (onRunStart):');
12 | }
13 | onTestFileStart(test) {
14 | console.debug('HooksDefaultReporter: (onTestFileStart):', test.path);
15 | }
16 | onTestStart(test) {
17 | console.debug('HooksDefaultReporter: (onTestStart):', test);
18 | }
19 | onTestResult(test) {
20 | console.debug('HooksDefaultReporter: (onTestResult):', test.path);
21 | }
22 | onTestFileResult(test, testResult, aggregatedResult) {
23 | console.debug('HooksDefaultReporter: (onTestFileResult):', test.path);
24 | }
25 | onTestCaseResult(test, testCaseResult) {
26 | console.debug('HooksDefaultReporter: (onTestCaseResult):', test.path);
27 | }
28 | onRunComplete(contexts, results) {
29 | console.debug('HooksDefaultReporter: (onRunComplete):');
30 | // console.debug('GlobalConfig: ', this._globalConfig);
31 | // console.debug('Options: ', this._options);
32 | }
33 | getLastError() {
34 | if (this._shouldFail) {
35 | return new Error('HooksDefaultReporter reported an error');
36 | }
37 | }
38 | }
39 |
40 | module.exports = extendWithLocal(HooksDefaultReporter, './localizedHooksReporter.local.js');
41 |
--------------------------------------------------------------------------------
/tests-ui/reporters/utilities.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | function extendWithLocal(Klass, path) {
4 | try {
5 | const Subclass = require(path);
6 | Object.setPrototypeOf(Subclass.prototype, Klass.prototype);
7 | Object.setPrototypeOf(Subclass, Klass);
8 | return Subclass;
9 | } catch (e) {
10 | console.log(`[Not an error] Failed to extendWithLocal: ${ path }`, e);
11 | return Klass;
12 | }
13 | }
14 |
15 | function makeBroadcaster(...broadcasters) {
16 | return function broadcastMessage(msg) {
17 | return Promise.all(broadcasters.map(b => b(msg)));
18 | };
19 | }
20 |
21 | module.exports.extendWithLocal = extendWithLocal;
22 | module.exports.makeBroadcaster = makeBroadcaster;
23 |
--------------------------------------------------------------------------------
/tests-ui/selectors.js:
--------------------------------------------------------------------------------
1 | export { SEL } from '../src/testability';
2 |
3 | export const MOD = {
4 | // ARIA_EXPANDED: `[aria-haspopup=true][aria-expanded=true]`,
5 | ATTR_NOT_DISABLED: ':not([disabled])',
6 | ATTR_DISABLED: '[disabled]',
7 | };
8 |
--------------------------------------------------------------------------------
/tests-ui/testInfoProvider.js:
--------------------------------------------------------------------------------
1 | import faker from 'faker';
2 | import './ensure_env';
3 | import { ensureEnvVariable } from '../src/shared/env';
4 | import { getRandomEmail } from '../src/shared/email';
5 |
6 | export function obtainTestInfo() {
7 |
8 | return {
9 | goodAddress: {
10 | address: `Testing Address: ${ faker.random.words(2) }`,
11 | timeRaw: new Date(new Date().setHours(0, 8)).toISOString()
12 | },
13 | email: getUniqueEmailAddressForTests()
14 | //phone: faker.phone.phoneNumber(),
15 | //message: faker.random.words()
16 | };
17 | }
18 |
19 | const testEmailAddress = ensureEnvVariable('FTGO_TEST_EMAIL_ADDRESS');
20 |
21 | export function getUniqueEmailAddressForTests() {
22 | return getRandomEmail(testEmailAddress, () => faker.random.alphaNumeric(8));
23 | }
24 |
25 | /**
26 | * @description Source: https://www.mattzeunert.com/2020/04/01/filling-out-a-date-input-with-puppeteer.html
27 | * Transforms a date into a string for typing-in into the input box
28 | * @param page
29 | * @param testInfo
30 | * @return {Promise}
31 | */
32 | export async function ensureLocalizedTimeString(page, testInfo) {
33 | const timeRaw = testInfo.goodAddress.timeRaw;
34 |
35 | testInfo.goodAddress.time = (await page.evaluate(
36 | d => new Date(d).toLocaleTimeString(navigator.language, {
37 | hour: '2-digit',
38 | minute: '2-digit'
39 | }),
40 | timeRaw
41 | )).replace(' ', '');
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/tests-ui/testWrapper.js:
--------------------------------------------------------------------------------
1 | import { existsSync, mkdirSync, writeFile, writeFileSync } from 'fs';
2 | import path from 'path';
3 | import { promisify } from 'util';
4 | import { DEFAULT_TIMEOUT } from './jest.setup';
5 | import { getQuicklyLocationPathnameAndSearch } from './puppeteerExtensions';
6 |
7 | const started = new Date();
8 | const memo = {
9 | testNumber: 1
10 | };
11 |
12 | const artifactsPath = path.resolve(__dirname, '..', 'ci-artifacts');
13 | if (!existsSync(artifactsPath)) {
14 | mkdirSync(artifactsPath, { recursive: true });
15 | }
16 |
17 | let adHocScreenshotsBunchCount = 0;
18 | let adHocScreenshotsCount = 0;
19 | /**
20 | * Captures stack, etc.
21 | * @param _test
22 | * @param context
23 | * @returns {function(*=, *=, *=): *}
24 | */
25 | export const testWrap = (_test, context, fallbackTimeout = 10) => {
26 |
27 | let errCounter = 0;
28 |
29 | process.on('unhandledRejection', (reason, promise) => {
30 | const savePath = `${ artifactsPath }/unhandledRejection_e2e_error_log_${ getTimestampPart(started) }_${ errCounter }.txt`;
31 | const savePathExtra = `${ artifactsPath }/unhandledRejection_e2e_error_log_${ getTimestampPart(started) }_${ errCounter++ }_ext.txt`;
32 | try {
33 | writeFileSync(savePath, reason);
34 | promise.catch((ex) => {
35 | writeFileSync(savePathExtra, `Exception: ${ ex }\nStack: ${ ex.stack }`);
36 | });
37 | } catch (ex) {}
38 | });
39 |
40 | let effectiveTest = _test;
41 |
42 | function only(...args) {
43 | effectiveTest = _test.only;
44 | return newTest(...args);
45 | }
46 |
47 | let newTest;
48 | return Object.assign(newTest = function newTest(name, afn, timeoutProduct = 50) {
49 | //console.log(`Setting a watched test(${ name }, async fn, ${ timeout })`);
50 | const effectiveTimeout = ((timeoutProduct !== undefined) ? (timeoutProduct <= 1000 ? (timeoutProduct * fallbackTimeout) : timeoutProduct) : fallbackTimeout || DEFAULT_TIMEOUT);
51 | return effectiveTest(name, function (...args) {
52 | //console.log(`Invoked a jest-test (${ name }, async fn, ${ timeout })`);
53 | context.consoleMsgs = [];
54 | let timeoutRef;
55 | const ts = new Date() - 0;
56 | return new Promise(async (rs, rj) => {
57 | timeoutRef = setTimeout(() => {
58 | try {
59 | console.log(`Elapsed ${ new Date() - ts } ms`);
60 | } catch (ex) {}
61 | rj(Object.assign(new Error('Test execution timeout, gathering stats'), { name: 'timeout' }));
62 | }, effectiveTimeout - 10); // just a bit earlier, that's all
63 | try {
64 | await afn(...args);
65 | } catch (ex) {
66 | clearTimeout(timeoutRef);
67 | return rj(ex);
68 | }
69 | clearTimeout(timeoutRef);
70 | rs();
71 | }).catch(async ex => {
72 | const { testNumber } = memo;
73 | memo.testNumber++;
74 | const { page } = context;
75 |
76 | if (page) {
77 |
78 | try {
79 | console.log(`Test "${ name }" failed. #${ testNumber }`);
80 | const failedLocation = await getQuicklyLocationPathnameAndSearch(page);
81 | console.log(`Url: ${ failedLocation }`);
82 |
83 | await makeHtmlDump(page, { testNumber, name, failedLocation });
84 | await makeScreenshot(page, { testNumber });
85 | await makeBrowsersConsoleDump(page, { testNumber, ex }, context);
86 |
87 | } catch (ex) {
88 | console.log(`Gathering stats has failed. Reason: ${ ex }`);
89 | }
90 |
91 | } else {
92 | console.log(`Test: "${ name }" failed. Page dump is impossible. 'page' is not set.`);
93 | }
94 | throw ex;
95 | });
96 | }, effectiveTimeout);
97 | }, { only });
98 | };
99 |
100 | async function makeBrowsersConsoleDump(page, { testNumber, ex }, memoObj) {
101 | try {
102 | const consoleDumpPath = `${ artifactsPath }/art_${ getTimestampPart(started) }_${ testNumber }_console.txt`;
103 | const consoleDump = memoObj.consoleMsgs
104 | .concat([ [ String(ex), ex?.message ?? 'unknown', ex?.stack ?? 'no stack' ] ])
105 | .map(entry => entry.join(' ')).join('\r\n');
106 | void await promisify(writeFile)(consoleDumpPath, consoleDump);
107 | console.log(`Browser's console dump saved: ${ consoleDumpPath }`);
108 | } catch (ex) {
109 | console.log(`Browser's console failed. Reason: ${ ex }`);
110 | }
111 | }
112 |
113 | export async function makeHtmlDump(page, { testNumber, name, failedLocation }) {
114 |
115 | try {
116 | const pageContentDumpPath = `${ artifactsPath }/art_${ getTimestampPart(started) }_${ testNumber }_dom.html`;
117 | const pageContent = await page.content();
118 | const effectiveDumpInfo = [
119 | ``,
120 | ``,
121 | pageContent
122 | ].join('\r\n').replace(new RegExp('