diff --git a/apps/trading/pages/_app.page.tsx b/apps/trading/pages/_app.page.tsx index 9d4ccc4d0..4bf2d3c89 100644 --- a/apps/trading/pages/_app.page.tsx +++ b/apps/trading/pages/_app.page.tsx @@ -1,6 +1,5 @@ import type { AppProps } from 'next/app'; import Head from 'next/head'; -import { useRouter } from 'next/router'; import { Navbar } from '../components/navbar'; import { t, ThemeContext, useThemeSwitcher } from '@vegaprotocol/react-helpers'; import { @@ -8,11 +7,7 @@ import { VegaManageDialog, VegaWalletProvider, } from '@vegaprotocol/wallet'; -import { - useEnvironment, - EnvironmentProvider, - NetworkSwitcherDialog, -} from '@vegaprotocol/environment'; +import { EnvironmentProvider } from '@vegaprotocol/environment'; import { Connectors } from '../lib/vega-connectors'; import { ThemeSwitcher } from '@vegaprotocol/ui-toolkit'; import { AppLoader } from '../components/app-loader'; @@ -21,9 +16,7 @@ import './styles.css'; import { useGlobalStore } from '../stores'; function AppBody({ Component, pageProps }: AppProps) { - const { push } = useRouter(); const store = useGlobalStore(); - const { VEGA_NETWORKS } = useEnvironment(); const [theme, toggleTheme] = useThemeSwitcher(); return ( @@ -61,15 +54,6 @@ function AppBody({ Component, pageProps }: AppProps) { dialogOpen={store.vegaWalletManageDialog} setDialogOpen={(open) => store.setVegaWalletManageDialog(open)} /> - store.setVegaNetworkSwitcherDialog(open)} - onConnect={({ network }) => { - if (VEGA_NETWORKS[network]) { - push(VEGA_NETWORKS[network] ?? ''); - } - }} - /> diff --git a/libs/environment/jest.config.js b/libs/environment/jest.config.js index 5894ddbc0..37ed3392f 100644 --- a/libs/environment/jest.config.js +++ b/libs/environment/jest.config.js @@ -6,4 +6,5 @@ module.exports = { }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '../../coverage/libs/environment', + setupFilesAfterEnv: ['./src/setup-tests.ts'], }; diff --git a/libs/environment/src/components/index.ts b/libs/environment/src/components/index.ts index 34e06b5c6..0d7165ff2 100644 --- a/libs/environment/src/components/index.ts +++ b/libs/environment/src/components/index.ts @@ -1,5 +1,3 @@ export * from './network-loader'; -export * from './network-switcher'; -export * from './network-switcher-dialog'; export * from './node-switcher'; export * from './node-switcher-dialog'; diff --git a/libs/environment/src/components/network-loader/network-loader.spec.tsx b/libs/environment/src/components/network-loader/network-loader.spec.tsx new file mode 100644 index 000000000..0348555d6 --- /dev/null +++ b/libs/environment/src/components/network-loader/network-loader.spec.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from 'react'; +import { ApolloProvider } from '@apollo/client'; +import { useEnvironment } from '../../hooks'; +import { render, screen } from '@testing-library/react'; +import { NetworkLoader } from './network-loader'; + +jest.mock('@apollo/client'); +jest.mock('../../hooks'); + +// @ts-ignore Typescript doesn't recognise mocked instances +ApolloProvider.mockImplementation(({ children }: { children: ReactNode }) => { + return children; +}); + +const SKELETON_TEXT = 'LOADING'; +const SUCCESS_TEXT = 'LOADED'; + +const createClient = jest.fn(); + +beforeEach(() => { + createClient.mockReset(); + createClient.mockImplementation(() => { + return jest.fn(); + }); +}); + +describe('Network loader', () => { + it('renders a skeleton when there is no vega url in the environment', () => { + // @ts-ignore Typescript doesn't recognise mocked instances + useEnvironment.mockImplementation(() => ({ + VEGA_URL: undefined, + })); + + render( + + {SUCCESS_TEXT} + + ); + + expect(screen.getByText(SKELETON_TEXT)).toBeInTheDocument(); + expect(() => screen.getByText(SUCCESS_TEXT)).toThrow(); + expect(createClient).not.toHaveBeenCalled(); + }); + + it('renders the child components wrapped in an apollo provider when the environment has a vega url', () => { + // @ts-ignore Typescript doesn't recognise mocked instances + useEnvironment.mockImplementation(() => ({ + VEGA_URL: 'http://vega.node', + })); + + render( + + {SUCCESS_TEXT} + + ); + + expect(() => screen.getByText(SKELETON_TEXT)).toThrow(); + expect(screen.getByText(SUCCESS_TEXT)).toBeInTheDocument(); + expect(createClient).toHaveBeenCalledWith('http://vega.node'); + }); +}); diff --git a/libs/environment/src/components/network-switcher-dialog/index.tsx b/libs/environment/src/components/network-switcher-dialog/index.tsx deleted file mode 100644 index 6577ad17b..000000000 --- a/libs/environment/src/components/network-switcher-dialog/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { NetworkSwitcherDialog } from './network-switcher-dialog'; diff --git a/libs/environment/src/components/network-switcher-dialog/network-switcher-dialog.tsx b/libs/environment/src/components/network-switcher-dialog/network-switcher-dialog.tsx deleted file mode 100644 index b1be6941f..000000000 --- a/libs/environment/src/components/network-switcher-dialog/network-switcher-dialog.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { ComponentProps } from 'react'; -import { t } from '@vegaprotocol/react-helpers'; -import type { Intent } from '@vegaprotocol/ui-toolkit'; -import { Dialog } from '@vegaprotocol/ui-toolkit'; -import { NetworkSwitcher } from '../network-switcher'; - -type NetworkSwitcherDialogProps = Pick< - ComponentProps, - 'onConnect' | 'onError' -> & { - dialogOpen: boolean; - setDialogOpen: (dialogOpen: boolean) => void; - intent?: Intent; -}; - -export const NetworkSwitcherDialog = ({ - dialogOpen, - setDialogOpen, - intent, - onConnect, - onError, -}: NetworkSwitcherDialogProps) => { - return ( - - setDialogOpen(false)} - /> - - ); -}; diff --git a/libs/environment/src/components/network-switcher/index.tsx b/libs/environment/src/components/network-switcher/index.tsx deleted file mode 100644 index d0042d5f6..000000000 --- a/libs/environment/src/components/network-switcher/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { NetworkSwitcher } from './network-switcher'; diff --git a/libs/environment/src/components/network-switcher/network-switcher.tsx b/libs/environment/src/components/network-switcher/network-switcher.tsx deleted file mode 100644 index f7ac3c0ad..000000000 --- a/libs/environment/src/components/network-switcher/network-switcher.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useForm, Controller } from 'react-hook-form'; -import { Button, Select } from '@vegaprotocol/ui-toolkit'; -import { t } from '@vegaprotocol/react-helpers'; -import { useEnvironment } from '../../hooks'; -import type { Networks } from '../../types'; - -type NetworkState = { - network: Networks; -}; - -type NetworkSwitcherProps = { - onConnect: (network: NetworkState) => void; - onError?: () => void; - onClose: () => void; -}; - -export const NetworkSwitcher = ({ onConnect }: NetworkSwitcherProps) => { - const { VEGA_ENV, VEGA_NETWORKS } = useEnvironment(); - const { control, handleSubmit } = useForm({ - defaultValues: { - network: VEGA_ENV, - }, - }); - - return ( -
-
- ( - - )} - /> -
-
- -
-
- ); -}; diff --git a/libs/environment/src/components/node-switcher/node-switcher.spec.tsx b/libs/environment/src/components/node-switcher/node-switcher.spec.tsx new file mode 100644 index 000000000..af7cacf0a --- /dev/null +++ b/libs/environment/src/components/node-switcher/node-switcher.spec.tsx @@ -0,0 +1,668 @@ +import { useState } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useEnvironment } from '../../hooks/use-environment'; +import { useNodes } from '../../hooks/use-nodes'; +import createMockClient from '../../hooks/mocks/apollo-client'; +import { STATS_QUERY } from '../../utils/request-node'; +import { NodeSwitcher } from './node-switcher'; +import { getErrorByType } from '../../utils/validate-node'; +import type { Configuration, NodeData } from '../../'; +import { Networks, ErrorType, CUSTOM_NODE_KEY } from '../../'; + +type NodeDataProp = 'responseTime' | 'block' | 'chain' | 'ssl'; + +jest.mock('../../hooks/use-environment'); +jest.mock('../../hooks/use-nodes'); + +const mockNodesImplementation = + ( + updateNodeUrlMock: jest.Mock, + getNodeState: typeof getValidNodeState = getValidNodeState + ) => + (config: Configuration) => { + const [{ state, clients }, setImplementation] = useState<{ + state: Record; + clients: Record>; + }>({ + state: createMockState(Networks.TESTNET, config.hosts), + clients: createMockClients(config.hosts), + }); + + return { + state, + clients, + updateNodeUrl: updateNodeUrlMock.mockImplementation( + (node: string, url: string) => { + setImplementation((prev) => ({ + state: { + ...prev.state, + [node]: getNodeState(Networks.TESTNET, url), + }, + clients: { + ...prev.clients, + [node]: createMockClient({ network: Networks.TESTNET }), + }, + })); + } + ), + updateNodeBlock: (node: string, value: number) => { + setImplementation((prev) => ({ + state: { + ...prev.state, + [node]: { + ...prev.state[node], + block: { + ...prev.state[node].block, + value, + }, + }, + }, + clients: prev.clients, + })); + }, + }; + }; + +const statsQueryMock = { + request: { + query: STATS_QUERY, + }, + result: { + data: { + statistics: { + blockHeight: 1234, + }, + }, + }, +}; + +const onConnect = jest.fn(); + +const HOSTS = ['https://host1.com', 'https://host2.com']; + +const enum STATES { + LOADING = 'is loading', + HAS_ERROR = 'has an error', +} + +const getValidNodeState = (env: Networks, url: string) => ({ + url, + initialized: true, + responseTime: { + isLoading: false, + hasError: false, + value: 10, + }, + block: { + isLoading: false, + hasError: false, + value: 123, + }, + ssl: { + isLoading: false, + hasError: false, + value: true, + }, + chain: { + isLoading: false, + hasError: false, + value: `${env.toLowerCase()}-1234`, + }, +}); + +const createMockState = (env: Networks, nodes: string[]) => + nodes.reduce( + (acc, node) => ({ + ...acc, + [node]: getValidNodeState(env, node), + }), + {} + ); + +const createMockClients = (nodes: string[]) => + nodes.reduce( + (acc, node) => ({ + ...acc, + [node]: createMockClient({ network: Networks.TESTNET }), + }), + {} + ); + +beforeEach(() => { + onConnect.mockReset(); + + // @ts-ignore Typescript doesn't recognise mocked instances + useEnvironment.mockImplementation(() => ({ + VEGA_ENV: Networks.TESTNET, + VEGA_URL: undefined, + })); + + // @ts-ignore Typescript doesn't recognise mocked instances + useNodes.mockImplementation((config: Configuration) => ({ + state: createMockState(Networks.TESTNET, config.hosts), + clients: createMockClients(config.hosts), + updateNodeUrl: jest.fn(), + updateNodeBlock: jest.fn(), + })); +}); + +describe('Node switcher', () => { + it('renders with empty config', () => { + render(); + + expect(() => screen.getAllByTestId('node')).toThrow(); + expect(screen.getByRole('radio', { checked: false })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + }); + + it('renders with the provided config nodes', () => { + render(); + + HOSTS.forEach((host) => { + expect( + screen.getByRole('radio', { checked: false, name: host }) + ).toBeInTheDocument(); + }); + expect( + screen.getByRole('radio', { checked: false, name: 'Other' }) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + }); + + it('marks the node in the environment as selected', () => { + // @ts-ignore Typescript doesn't recognise mocked instances + useEnvironment.mockImplementation(() => ({ + VEGA_ENV: Networks.TESTNET, + VEGA_URL: HOSTS[0], + })); + render(); + + HOSTS.forEach((host) => { + expect( + screen.getByRole('radio', { checked: host === HOSTS[0], name: host }) + ).toBeInTheDocument(); + }); + expect( + screen.getByRole('radio', { checked: false, name: 'Other' }) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect' })).not.toHaveAttribute( + 'disabled' + ); + }); + + it.each` + dataProp | state + ${'responseTime'} | ${STATES.LOADING} + ${'responseTime'} | ${STATES.HAS_ERROR} + ${'block'} | ${STATES.LOADING} + ${'block'} | ${STATES.HAS_ERROR} + ${'chain'} | ${STATES.LOADING} + ${'chain'} | ${STATES.HAS_ERROR} + ${'ssl'} | ${STATES.LOADING} + ${'ssl'} | ${STATES.HAS_ERROR} + `( + 'disables selecting a node when the $dataProp $state', + ({ dataProp, state }: { dataProp: NodeDataProp; state: STATES }) => { + const mockUrl = 'https://host.url'; + const mockConfig = { + hosts: [mockUrl], + }; + + // @ts-ignore Typescript doesn't recognise mocked instances + useNodes.mockImplementation((config: Configuration) => { + const nodeState = getValidNodeState(Networks.TESTNET, mockUrl); + return { + state: { + [mockUrl]: { + ...nodeState, + [dataProp]: { + ...nodeState[dataProp], + isLoading: + state === STATES.LOADING + ? true + : nodeState[dataProp].isLoading, + hasError: + state === STATES.HAS_ERROR + ? true + : nodeState[dataProp].hasError, + value: undefined, + }, + }, + }, + clients: createMockClients(config.hosts), + updateNodeUrl: jest.fn(), + updateNodeBlock: jest.fn(), + }; + }); + + render( + + + + ); + + expect(screen.getByRole('radio', { name: mockUrl })).toHaveAttribute( + 'disabled' + ); + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + } + ); + + it('disables selecting a node when it has an invalid url', () => { + const mockUrl = 'not-valid-url'; + const mockConfig = { + hosts: [mockUrl], + }; + + // @ts-ignore Typescript doesn't recognise mocked instances + useNodes.mockImplementation((config: Configuration) => ({ + state: { + [mockUrl]: getValidNodeState(Networks.TESTNET, mockUrl), + }, + clients: createMockClients(config.hosts), + updateNodeUrl: jest.fn(), + updateNodeBlock: jest.fn(), + })); + + render( + + + + ); + + expect(screen.getByRole('radio', { name: mockUrl })).toHaveAttribute( + 'disabled' + ); + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + }); + + it('disables selecting a node when it is on an incorrect network', () => { + const mockUrl = 'https://mock.url'; + const mockConfig = { + hosts: [mockUrl], + }; + + // @ts-ignore Typescript doesn't recognise mocked instances + useNodes.mockImplementation((config: Configuration) => { + const nodeState = getValidNodeState(Networks.TESTNET, mockUrl); + return { + state: { + [mockUrl]: { + ...nodeState, + chain: { + ...nodeState.chain, + value: `some-network-id`, + }, + }, + }, + clients: createMockClients(config.hosts), + updateNodeUrl: jest.fn(), + updateNodeBlock: jest.fn(), + }; + }); + + render( + + + + ); + + expect(screen.getByRole('radio', { name: mockUrl })).toHaveAttribute( + 'disabled' + ); + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + }); + + it('allows connecting to a valid node', () => { + render(); + + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + fireEvent.click(screen.getByRole('radio', { name: HOSTS[0] })); + + expect(screen.getByRole('button', { name: 'Connect' })).not.toHaveAttribute( + 'disabled' + ); + fireEvent.click(screen.getByRole('button', { name: 'Connect' })); + + expect(onConnect).toHaveBeenCalledWith(HOSTS[0]); + }); + + it('allows checking a custom node', () => { + const updateNodeUrlMock = jest.fn(); + + // @ts-ignore Typescript doesn't recognise mocked instances + useNodes.mockImplementation((config: Configuration) => ({ + state: createMockState(Networks.TESTNET, config.hosts), + clients: createMockClients(config.hosts), + updateNodeUrl: updateNodeUrlMock, + updateNodeBlock: jest.fn(), + })); + + const mockUrl = 'https://custom.url'; + render( + + + + ); + + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + fireEvent.click(screen.getByRole('radio', { name: 'Other' })); + expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + fireEvent.change(screen.getByRole('textbox'), { + target: { + value: mockUrl, + }, + }); + + expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute( + 'aria-disabled', + 'false' + ); + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + fireEvent.click(screen.getByRole('link', { name: 'Check' })); + + expect(updateNodeUrlMock).toHaveBeenCalledWith(CUSTOM_NODE_KEY, mockUrl); + }); + + it('allows connecting to a custom node', () => { + const mockUrl = 'https://custom.url'; + const updateNodeUrlMock = jest.fn(); + + // @ts-ignore Typescript doesn't recognise mocked instances + useNodes.mockImplementation(mockNodesImplementation(updateNodeUrlMock)); + + render( + + + + ); + + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + fireEvent.click(screen.getByRole('radio', { name: 'Other' })); + expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + fireEvent.change(screen.getByRole('textbox'), { + target: { + value: mockUrl, + }, + }); + + expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute( + 'aria-disabled', + 'false' + ); + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + fireEvent.click(screen.getByRole('link', { name: 'Check' })); + + expect(screen.getByRole('button', { name: 'Connect' })).not.toHaveAttribute( + 'disabled' + ); + fireEvent.click(screen.getByRole('button', { name: 'Connect' })); + + expect(onConnect).toHaveBeenCalledWith(mockUrl); + }); + + it.each` + dataProp | state + ${'responseTime'} | ${STATES.LOADING} + ${'responseTime'} | ${STATES.HAS_ERROR} + ${'block'} | ${STATES.LOADING} + ${'block'} | ${STATES.HAS_ERROR} + ${'chain'} | ${STATES.LOADING} + ${'chain'} | ${STATES.HAS_ERROR} + ${'ssl'} | ${STATES.LOADING} + ${'ssl'} | ${STATES.HAS_ERROR} + `( + 'disables selecting a custom node when the $dataProp $state', + ({ dataProp, state }: { dataProp: NodeDataProp; state: STATES }) => { + const mockUrl = 'https://custom.url'; + const updateNodeUrlMock = jest.fn(); + + // @ts-ignore Typescript doesn't recognise mocked instances + useNodes.mockImplementation( + mockNodesImplementation(updateNodeUrlMock, (env) => { + const nodeState = getValidNodeState(env, mockUrl); + return { + ...nodeState, + [dataProp]: { + ...nodeState[dataProp], + isLoading: + state === STATES.LOADING ? true : nodeState[dataProp].isLoading, + hasError: + state === STATES.HAS_ERROR + ? true + : nodeState[dataProp].hasError, + value: undefined, + }, + }; + }) + ); + + render( + + + + ); + + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + fireEvent.click(screen.getByRole('radio', { name: 'Other' })); + expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + fireEvent.change(screen.getByRole('textbox'), { + target: { + value: mockUrl, + }, + }); + + expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute( + 'aria-disabled', + 'false' + ); + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + fireEvent.click(screen.getByRole('link', { name: 'Check' })); + + if (state === STATES.LOADING) { + // eslint-disable-next-line jest/no-conditional-expect + expect(screen.getByRole('link', { name: 'Checking' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + } + + if (state === STATES.HAS_ERROR) { + // eslint-disable-next-line jest/no-conditional-expect + expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute( + 'aria-disabled', + 'false' + ); + } + + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + if (state === STATES.HAS_ERROR) { + const expectedErrorType = + dataProp === 'ssl' ? ErrorType.SSL_ERROR : ErrorType.CONNECTION_ERROR; + const error = getErrorByType( + expectedErrorType, + Networks.TESTNET, + mockUrl + ); + + // eslint-disable-next-line jest/no-conditional-expect + expect(error?.headline).not.toBeNull(); + // eslint-disable-next-line jest/no-conditional-expect + expect(screen.getByText(error?.headline ?? '')).toBeInTheDocument(); + } + } + ); + + it('disables selecting a custom node when it has an invalid url', () => { + const mockUrl = 'not-valid-url'; + const updateNodeUrlMock = jest.fn(); + + // @ts-ignore Typescript doesn't recognise mocked instances + useNodes.mockImplementation(mockNodesImplementation(updateNodeUrlMock)); + + render( + + + + ); + + fireEvent.click(screen.getByRole('radio', { name: 'Other' })); + fireEvent.change(screen.getByRole('textbox'), { + target: { + value: mockUrl, + }, + }); + fireEvent.click(screen.getByRole('link', { name: 'Check' })); + + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + const error = getErrorByType( + ErrorType.INVALID_URL, + Networks.TESTNET, + mockUrl + ); + + expect(error?.headline).not.toBeNull(); + expect(screen.getByText(error?.headline ?? '')).toBeInTheDocument(); + }); + + it('disables selecting a custom node when it is on an incorrect network', () => { + const mockUrl = 'https://mock.url'; + const updateNodeUrlMock = jest.fn(); + + // @ts-ignore Typescript doesn't recognise mocked instances + useNodes.mockImplementation( + mockNodesImplementation(updateNodeUrlMock, (env, url) => { + const nodeState = getValidNodeState(env, url); + return { + ...nodeState, + chain: { + ...nodeState.chain, + value: 'network-chain-id', + }, + }; + }) + ); + + render( + + + + ); + + fireEvent.click(screen.getByRole('radio', { name: 'Other' })); + fireEvent.change(screen.getByRole('textbox'), { + target: { + value: mockUrl, + }, + }); + fireEvent.click(screen.getByRole('link', { name: 'Check' })); + + expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute( + 'disabled' + ); + + const error = getErrorByType( + ErrorType.INVALID_NETWORK, + Networks.TESTNET, + mockUrl + ); + + expect(error?.headline).not.toBeNull(); + expect(screen.getByText(error?.headline ?? '')).toBeInTheDocument(); + }); + + it.each` + description | errorType + ${'the node has an invalid url'} | ${ErrorType.INVALID_URL} + ${'the node is on an invalid network'} | ${ErrorType.INVALID_NETWORK} + ${'the node has an ssl issue'} | ${ErrorType.SSL_ERROR} + ${'the node cannot be reached'} | ${ErrorType.CONNECTION_ERROR} + ${'none of the config nodes can be connected to'} | ${ErrorType.CONNECTION_ERROR_ALL} + ${'the config cannot be loaded'} | ${ErrorType.CONFIG_LOAD_ERROR} + ${'the config is invalid'} | ${ErrorType.CONFIG_VALIDATION_ERROR} + `( + 'displays initial error when $description', + ({ errorType }: { errorType: ErrorType }) => { + const mockEnvUrl = 'https://mock.url'; + + // @ts-ignore Typescript doesn't recognise mocked instances + useEnvironment.mockImplementation(() => ({ + VEGA_ENV: Networks.TESTNET, + VEGA_URL: mockEnvUrl, + })); + + render( + + ); + + const error = getErrorByType(errorType, Networks.TESTNET, mockEnvUrl); + + expect(error?.headline).not.toBeNull(); + expect(error?.message).not.toBeNull(); + expect(screen.getByText(error?.headline ?? '')).toBeInTheDocument(); + expect(screen.getByText(error?.message ?? '')).toBeInTheDocument(); + } + ); +}); diff --git a/libs/environment/src/components/node-switcher/node-switcher.tsx b/libs/environment/src/components/node-switcher/node-switcher.tsx index 18f8d63e3..5f3809c89 100644 --- a/libs/environment/src/components/node-switcher/node-switcher.tsx +++ b/libs/environment/src/components/node-switcher/node-switcher.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import { t } from '@vegaprotocol/react-helpers'; import { RadioGroup, @@ -55,14 +55,9 @@ export const NodeSwitcher = ({ const [nodeRadio, setNodeRadio] = useState( getDefaultNode(config.hosts, VEGA_URL) ); - const { state, clients, updateNodeUrl, updateNodeBlock } = useNodes( - VEGA_ENV, - config - ); - const highestBlock = useMemo( - () => getHighestBlock(VEGA_ENV, state), - [VEGA_ENV, state] - ); + const { state, clients, updateNodeUrl, updateNodeBlock } = useNodes(config); + const highestBlock = getHighestBlock(VEGA_ENV, state); + const customUrl = state[CUSTOM_NODE_KEY]?.url; const onSubmit = (node: ReturnType) => { @@ -71,12 +66,7 @@ export const NodeSwitcher = ({ } }; - const isSubmitDisabled = getIsFormDisabled( - nodeRadio, - customNodeText, - VEGA_ENV, - state - ); + const isSubmitDisabled = getIsFormDisabled(nodeRadio, VEGA_ENV, state); const customNodeData = nodeRadio && @@ -94,7 +84,12 @@ export const NodeSwitcher = ({ return (
-
onSubmit(nodeRadio)}> + { + event.preventDefault(); + onSubmit(nodeRadio); + }} + >

{t('Select a GraphQL node to connect to:')}

@@ -124,7 +119,7 @@ export const NodeSwitcher = ({ highestBlock={highestBlock} setBlock={(block) => updateNodeBlock(node, block)} > -
+
{ }); afterAll(() => { - delete process.env['NX_VEGA_URL']; - delete process.env['NX_VEGA_ENV']; - delete process.env['NX_VEGA_CONFIG_URL']; - delete process.env['NX_VEGA_NETWORKS']; - delete process.env['NX_ETHEREUM_PROVIDER_URL']; - delete process.env['NX_ETHERSCAN_URL']; - delete process.env['NX_GIT_BRANCH']; - delete process.env['NX_GIT_ORIGIN_URL']; - delete process.env['NX_GIT_COMMIT_HASH']; - delete process.env['NX_GITHUB_FEEDBACK_URL']; - jest.clearAllMocks(); }); diff --git a/libs/environment/src/hooks/use-environment.tsx b/libs/environment/src/hooks/use-environment.tsx index 05b5705d5..1d47ae3bf 100644 --- a/libs/environment/src/hooks/use-environment.tsx +++ b/libs/environment/src/hooks/use-environment.tsx @@ -12,7 +12,7 @@ import { getIsNodeLoading, } from '../utils/validate-node'; import { ErrorType } from '../types'; -import type { Environment, RawEnvironment, NodeData } from '../types'; +import type { Environment, Networks, RawEnvironment, NodeData } from '../types'; type EnvironmentProviderProps = { definitions?: Partial; @@ -26,8 +26,15 @@ export type EnvironmentState = Environment & { const EnvironmentContext = createContext({} as EnvironmentState); -const hasFinishedLoading = (node: NodeData) => - node.initialized && !getIsNodeLoading(node) && !node.verified; +const hasLoaded = (env: Networks, node: NodeData) => + node.initialized && + !getIsNodeLoading(node) && + getErrorType(env, node) === null; + +const hasFailedLoading = (env: Networks, node: NodeData) => + node.initialized && + !getIsNodeLoading(node) && + getErrorType(env, node) !== null; export const EnvironmentProvider = ({ definitions, @@ -47,14 +54,14 @@ export const EnvironmentProvider = ({ error && console.warn(error.headline); } }); - const { state: nodes, clients } = useNodes(environment.VEGA_ENV, config); + const { state: nodes, clients } = useNodes(config); const nodeKeys = Object.keys(nodes); useEffect(() => { if (!environment.VEGA_URL) { - const successfulNodeKey = nodeKeys.find( - (key) => nodes[key].verified - ) as keyof typeof nodes; + const successfulNodeKey = nodeKeys.find((key) => + hasLoaded(environment.VEGA_ENV, nodes[key]) + ); if (successfulNodeKey && nodes[successfulNodeKey]) { Object.keys(clients).forEach((node) => clients[node]?.stop()); updateEnvironment((prevEnvironment) => ({ @@ -81,8 +88,9 @@ export const EnvironmentProvider = ({ // if the config doesn't contain nodes the app can connect to if ( nodeKeys.length > 0 && - nodeKeys.filter((key) => hasFinishedLoading(nodes[key])).length === - nodeKeys.length + nodeKeys.filter((key) => + hasFailedLoading(environment.VEGA_ENV, nodes[key]) + ).length === nodeKeys.length ) { Object.keys(clients).forEach((node) => clients[node]?.stop()); setNetworkError(ErrorType.CONNECTION_ERROR_ALL); diff --git a/libs/environment/src/hooks/use-nodes.spec.tsx b/libs/environment/src/hooks/use-nodes.spec.tsx index 767f0021b..2cb3cbbdd 100644 --- a/libs/environment/src/hooks/use-nodes.spec.tsx +++ b/libs/environment/src/hooks/use-nodes.spec.tsx @@ -2,19 +2,16 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { ApolloClient } from '@apollo/client'; import createClient from '../utils/apollo-client'; import { useNodes } from './use-nodes'; -import { Networks } from '../types'; import createMockClient, { getMockStatisticsResult, } from './mocks/apollo-client'; jest.mock('../utils/apollo-client'); -const MOCK_ENV = Networks.DEVNET; const MOCK_DURATION = 1073; const initialState = { url: '', - verified: false, initialized: false, responseTime: { isLoading: false, @@ -61,7 +58,7 @@ afterAll(() => { describe('useNodes hook', () => { it('returns the default state when empty config provided', () => { - const { result } = renderHook(() => useNodes(MOCK_ENV, { hosts: [] })); + const { result } = renderHook(() => useNodes({ hosts: [] })); expect(result.current.state).toEqual({}); }); @@ -69,13 +66,12 @@ describe('useNodes hook', () => { it('sets loading state while waiting for the results', async () => { const node = 'https://some.url'; const { result, waitForNextUpdate } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [node] }) + useNodes({ hosts: [node] }) ); expect(result.current.state[node]).toEqual({ ...initialState, url: node, - verified: false, initialized: true, responseTime: { ...initialState.responseTime, @@ -101,9 +97,7 @@ describe('useNodes hook', () => { it('sets statistics results', async () => { const mockResult = getMockStatisticsResult(); const node = 'https://some.url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [node] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] })); await waitFor(() => { expect(result.current.state[node].block).toEqual({ @@ -128,9 +122,7 @@ describe('useNodes hook', () => { it('sets subscription result', async () => { const node = 'https://some.url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [node] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] })); await waitFor(() => { expect(result.current.state[node].ssl).toEqual({ @@ -143,9 +135,7 @@ describe('useNodes hook', () => { it('sets error when host in not a valid url', async () => { const node = 'not-url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [node] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] })); await waitFor(() => { expect(result.current.state[node].block.hasError).toBe(true); @@ -162,9 +152,7 @@ describe('useNodes hook', () => { ); const node = 'https://some.url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [node] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] })); await waitFor(() => { expect(result.current.state[node].block).toEqual({ @@ -194,9 +182,7 @@ describe('useNodes hook', () => { ); const node = 'https://some.url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [node] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] })); await waitFor(() => { expect(result.current.state[node].ssl).toEqual({ @@ -210,9 +196,7 @@ describe('useNodes hook', () => { it('allows updating block values', async () => { const mockResult = getMockStatisticsResult(); const node = 'https://some.url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [node] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] })); await waitFor(() => { expect(result.current.state[node].block.value).toEqual( @@ -232,9 +216,7 @@ describe('useNodes hook', () => { it('does nothing when calling the block update on a non-existing node', async () => { const mockResult = getMockStatisticsResult(); const node = 'https://some.url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [node] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] })); await waitFor(() => { expect(result.current.state[node].block.value).toEqual( @@ -251,9 +233,7 @@ describe('useNodes hook', () => { it('adds new node', async () => { const node = 'custom-node-key'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [] })); expect(result.current.state[node]).toEqual(undefined); @@ -269,9 +249,7 @@ describe('useNodes hook', () => { it('sets new url for node', async () => { const node = 'https://some.url'; const newUrl = 'https://some-other.url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [node] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] })); act(() => { result.current.updateNodeUrl(node, newUrl); @@ -285,9 +263,7 @@ describe('useNodes hook', () => { it('sets error when custom node has an invalid url', async () => { const node = 'node-key'; const url = 'not-url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [] })); expect(result.current.state[node]).toBe(undefined); @@ -312,9 +288,7 @@ describe('useNodes hook', () => { const node = 'node-key'; const url = 'https://some.url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [] })); expect(result.current.state[node]).toBe(undefined); @@ -351,9 +325,7 @@ describe('useNodes hook', () => { const node = 'node-key'; const url = 'https://some.url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [] })); expect(result.current.state[node]).toBe(undefined); @@ -374,7 +346,7 @@ describe('useNodes hook', () => { const url1 = 'https://some.url'; const url2 = 'https://some-other.url'; const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [url1, url2] }) + useNodes({ hosts: [url1, url2] }) ); await waitFor(() => { @@ -386,9 +358,7 @@ describe('useNodes hook', () => { it('exposes a client for the custom node', async () => { const node = 'node-key'; const url = 'https://some.url'; - const { result, waitFor } = renderHook(() => - useNodes(MOCK_ENV, { hosts: [] }) - ); + const { result, waitFor } = renderHook(() => useNodes({ hosts: [] })); act(() => { result.current.updateNodeUrl(node, url); diff --git a/libs/environment/src/hooks/use-nodes.tsx b/libs/environment/src/hooks/use-nodes.tsx index 50f2eff81..0ce98bc16 100644 --- a/libs/environment/src/hooks/use-nodes.tsx +++ b/libs/environment/src/hooks/use-nodes.tsx @@ -3,8 +3,7 @@ import { useState, useEffect, useReducer } from 'react'; import { produce } from 'immer'; import type createClient from '../utils/apollo-client'; import { initializeNode } from '../utils/initialize-node'; -import { getErrorType, getIsNodeLoading } from '../utils/validate-node'; -import type { NodeData, Configuration, Networks } from '../types'; +import type { NodeData, Configuration } from '../types'; type StatisticsPayload = { block: NodeData['block']['value']; @@ -59,7 +58,6 @@ function withError(value?: T) { const getNodeData = (url?: string): NodeData => ({ url: url ?? '', - verified: false, initialized: false, responseTime: withData(), block: withData(), @@ -108,88 +106,81 @@ const initializeNodes = ( ); }; -const reducer = - (env: Networks) => (state: Record, action: Action) => { - switch (action.type) { - case ACTIONS.GET_STATISTICS: - return produce(state, (state) => { - if (!state[action.node]) { - state[action.node] = getNodeData(action.payload?.url); - } - state[action.node].url = action.payload?.url ?? ''; - state[action.node].initialized = true; - state[action.node].block.isLoading = true; - state[action.node].chain.isLoading = true; - state[action.node].responseTime.isLoading = true; - }); - case ACTIONS.GET_STATISTICS_SUCCESS: - return produce(state, (state) => { - if (!state[action.node]) return; - state[action.node].block = withData(action.payload?.block); - state[action.node].chain = withData(action.payload?.chain); - state[action.node].responseTime = withData( - action.payload?.responseTime - ); - state[action.node].verified = - !getIsNodeLoading(state[action.node]) && - getErrorType(env, state[action.node]) === null; - }); - case ACTIONS.GET_STATISTICS_FAILURE: - return produce(state, (state) => { - if (!state[action.node]) return; - state[action.node].block = withError(); - state[action.node].chain = withError(); - state[action.node].responseTime = withError(); - }); - case ACTIONS.CHECK_SUBSCRIPTION: - return produce(state, (state) => { - if (!state[action.node]) { - state[action.node] = getNodeData(action.payload?.url); - } - state[action.node].url = action.payload?.url ?? ''; - state[action.node].ssl.isLoading = true; - state[action.node].initialized = true; - }); - case ACTIONS.CHECK_SUBSCRIPTION_SUCCESS: - return produce(state, (state) => { - if (!state[action.node]) return; - state[action.node].ssl = withData(true); - state[action.node].verified = - !getIsNodeLoading(state[action.node]) && - getErrorType(env, state[action.node]) === null; - }); - case ACTIONS.CHECK_SUBSCRIPTION_FAILURE: - return produce(state, (state) => { - if (!state[action.node]) return; - state[action.node].ssl = withError(); - }); - case ACTIONS.ADD_NODE: - return produce(state, (state) => { - state[action.node] = getNodeData(); - }); - case ACTIONS.UPDATE_NODE_URL: - return produce(state, (state) => { - const existingNode = Object.keys(state).find( - (node) => - action.node !== node && state[node].url === action.payload?.url - ); - state[action.node] = existingNode - ? state[existingNode] - : getNodeData(action.payload?.url); - }); - case ACTIONS.UPDATE_NODE_BLOCK: - return produce(state, (state) => { - if (!state[action.node]) return; - state[action.node].block.value = action.payload; - }); - default: - return state; - } - }; +const reducer = (state: Record, action: Action) => { + switch (action.type) { + case ACTIONS.GET_STATISTICS: + return produce(state, (state) => { + if (!state[action.node]) { + state[action.node] = getNodeData(action.payload?.url); + } + state[action.node].url = action.payload?.url ?? ''; + state[action.node].initialized = true; + state[action.node].block.isLoading = true; + state[action.node].chain.isLoading = true; + state[action.node].responseTime.isLoading = true; + }); + case ACTIONS.GET_STATISTICS_SUCCESS: + return produce(state, (state) => { + if (!state[action.node]) return; + state[action.node].block = withData(action.payload?.block); + state[action.node].chain = withData(action.payload?.chain); + state[action.node].responseTime = withData( + action.payload?.responseTime + ); + }); + case ACTIONS.GET_STATISTICS_FAILURE: + return produce(state, (state) => { + if (!state[action.node]) return; + state[action.node].block = withError(); + state[action.node].chain = withError(); + state[action.node].responseTime = withError(); + }); + case ACTIONS.CHECK_SUBSCRIPTION: + return produce(state, (state) => { + if (!state[action.node]) { + state[action.node] = getNodeData(action.payload?.url); + } + state[action.node].url = action.payload?.url ?? ''; + state[action.node].ssl.isLoading = true; + state[action.node].initialized = true; + }); + case ACTIONS.CHECK_SUBSCRIPTION_SUCCESS: + return produce(state, (state) => { + if (!state[action.node]) return; + state[action.node].ssl = withData(true); + }); + case ACTIONS.CHECK_SUBSCRIPTION_FAILURE: + return produce(state, (state) => { + if (!state[action.node]) return; + state[action.node].ssl = withError(); + }); + case ACTIONS.ADD_NODE: + return produce(state, (state) => { + state[action.node] = getNodeData(); + }); + case ACTIONS.UPDATE_NODE_URL: + return produce(state, (state) => { + const existingNode = Object.keys(state).find( + (node) => + action.node !== node && state[node].url === action.payload?.url + ); + state[action.node] = existingNode + ? state[existingNode] + : getNodeData(action.payload?.url); + }); + case ACTIONS.UPDATE_NODE_BLOCK: + return produce(state, (state) => { + if (!state[action.node]) return; + state[action.node].block.value = action.payload; + }); + default: + return state; + } +}; -export const useNodes = (env: Networks, config?: Configuration) => { +export const useNodes = (config?: Configuration) => { const [clients, setClients] = useState({}); - const [state, dispatch] = useReducer(reducer(env), getInitialState(config)); + const [state, dispatch] = useReducer(reducer, getInitialState(config)); const configCacheKey = config?.hosts.join(';'); const allUrls = Object.keys(state).map((node) => state[node].url); diff --git a/libs/environment/src/setup-tests.ts b/libs/environment/src/setup-tests.ts new file mode 100644 index 000000000..40db815a1 --- /dev/null +++ b/libs/environment/src/setup-tests.ts @@ -0,0 +1,8 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; +import ResizeObserver from 'resize-observer-polyfill'; + +global.ResizeObserver = ResizeObserver; diff --git a/libs/environment/src/types.ts b/libs/environment/src/types.ts index 86b5213ff..f8aed909c 100644 --- a/libs/environment/src/types.ts +++ b/libs/environment/src/types.ts @@ -37,7 +37,6 @@ type NodeCheck = { export type NodeData = { url: string; - verified: boolean; initialized: boolean; ssl: NodeCheck; block: NodeCheck; diff --git a/libs/environment/src/utils/validate-node.tsx b/libs/environment/src/utils/validate-node.tsx index 43ef8113f..7f78ee4ff 100644 --- a/libs/environment/src/utils/validate-node.tsx +++ b/libs/environment/src/utils/validate-node.tsx @@ -1,5 +1,5 @@ import { t } from '@vegaprotocol/react-helpers'; -import { CUSTOM_NODE_KEY, ErrorType } from '../types'; +import { ErrorType } from '../types'; import type { Networks, NodeData } from '../types'; export const getIsNodeLoading = (node?: NodeData): boolean => { @@ -40,7 +40,6 @@ export const getIsNodeDisabled = (env: Networks, data?: NodeData) => { export const getIsFormDisabled = ( currentNode: string | undefined, - inputText: string, env: Networks, state: Record ) => { @@ -48,16 +47,8 @@ export const getIsFormDisabled = ( return true; } - if ( - currentNode === CUSTOM_NODE_KEY && - state[CUSTOM_NODE_KEY] && - inputText !== state[CUSTOM_NODE_KEY].url - ) { - return true; - } - const data = state[currentNode]; - return getIsNodeDisabled(env, data); + return data ? getIsNodeDisabled(env, data) : true; }; export const getErrorByType = ( diff --git a/libs/environment/tsconfig.spec.json b/libs/environment/tsconfig.spec.json index 0f85c5081..987ab7cfc 100644 --- a/libs/environment/tsconfig.spec.json +++ b/libs/environment/tsconfig.spec.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "commonjs", - "types": ["jest", "node"] + "types": ["jest", "node", "@testing-library/jest-dom"] }, "include": [ "**/*.test.ts",