fix: disallow updating custom node before validation (#931)

* fix: disallow updating custom node before validation

* fix: format

* fix: lint

* feat: add node switcher component tests

* fix: environment filter
This commit is contained in:
botond 2022-08-04 10:14:49 +01:00 committed by GitHub
parent ab0b762cd2
commit 45d05b38f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 862 additions and 286 deletions

View File

@ -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)}
/>
<NetworkSwitcherDialog
dialogOpen={store.vegaNetworkSwitcherDialog}
setDialogOpen={(open) => store.setVegaNetworkSwitcherDialog(open)}
onConnect={({ network }) => {
if (VEGA_NETWORKS[network]) {
push(VEGA_NETWORKS[network] ?? '');
}
}}
/>
</AppLoader>
</div>
</ThemeContext.Provider>

View File

@ -6,4 +6,5 @@ module.exports = {
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/environment',
setupFilesAfterEnv: ['./src/setup-tests.ts'],
};

View File

@ -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';

View File

@ -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(
<NetworkLoader skeleton={SKELETON_TEXT} createClient={createClient}>
{SUCCESS_TEXT}
</NetworkLoader>
);
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(
<NetworkLoader skeleton={SKELETON_TEXT} createClient={createClient}>
{SUCCESS_TEXT}
</NetworkLoader>
);
expect(() => screen.getByText(SKELETON_TEXT)).toThrow();
expect(screen.getByText(SUCCESS_TEXT)).toBeInTheDocument();
expect(createClient).toHaveBeenCalledWith('http://vega.node');
});
});

View File

@ -1 +0,0 @@
export { NetworkSwitcherDialog } from './network-switcher-dialog';

View File

@ -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<typeof NetworkSwitcher>,
'onConnect' | 'onError'
> & {
dialogOpen: boolean;
setDialogOpen: (dialogOpen: boolean) => void;
intent?: Intent;
};
export const NetworkSwitcherDialog = ({
dialogOpen,
setDialogOpen,
intent,
onConnect,
onError,
}: NetworkSwitcherDialogProps) => {
return (
<Dialog
open={dialogOpen}
onChange={setDialogOpen}
title={t('Choose a network')}
intent={intent}
>
<NetworkSwitcher
onConnect={onConnect}
onError={onError}
onClose={() => setDialogOpen(false)}
/>
</Dialog>
);
};

View File

@ -1 +0,0 @@
export { NetworkSwitcher } from './network-switcher';

View File

@ -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<NetworkState>({
defaultValues: {
network: VEGA_ENV,
},
});
return (
<form onSubmit={handleSubmit(onConnect)}>
<div className="py-16">
<Controller
name="network"
control={control}
render={({ field }) => (
<Select value={field.value} onChange={field.onChange}>
{Object.keys(VEGA_NETWORKS).map((network, index) => (
<option key={index} value={network}>
{network}
</option>
))}
</Select>
)}
/>
</div>
<div className="py-16">
<Button data-testid="connect-network" type="submit">
{t('Connect')}
</Button>
</div>
</form>
);
};

View File

@ -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<string, NodeData>;
clients: Record<string, ReturnType<typeof createMockClients>>;
}>({
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(<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />);
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(<NodeSwitcher config={{ hosts: HOSTS }} onConnect={onConnect} />);
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(<NodeSwitcher config={{ hosts: HOSTS }} onConnect={onConnect} />);
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(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={mockConfig} onConnect={onConnect} />
</MockedProvider>
);
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(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={mockConfig} onConnect={onConnect} />
</MockedProvider>
);
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(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={mockConfig} onConnect={onConnect} />
</MockedProvider>
);
expect(screen.getByRole('radio', { name: mockUrl })).toHaveAttribute(
'disabled'
);
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
'disabled'
);
});
it('allows connecting to a valid node', () => {
render(<NodeSwitcher config={{ hosts: HOSTS }} onConnect={onConnect} />);
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(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
</MockedProvider>
);
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(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
</MockedProvider>
);
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(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
</MockedProvider>
);
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(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
</MockedProvider>
);
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(
<MockedProvider mocks={[statsQueryMock]}>
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
</MockedProvider>
);
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(
<NodeSwitcher
config={{ hosts: HOSTS }}
initialErrorType={errorType}
onConnect={onConnect}
/>
);
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();
}
);
});

View File

@ -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<typeof getDefaultNode>) => {
@ -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 (
<div className="text-black dark:text-white w-full lg:min-w-[800px]">
<NodeError {...(customNodeError || networkError)} />
<form onSubmit={() => onSubmit(nodeRadio)}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(nodeRadio);
}}
>
<p className="text-body-large font-bold mt-16 mb-32">
{t('Select a GraphQL node to connect to:')}
</p>
@ -124,7 +119,7 @@ export const NodeSwitcher = ({
highestBlock={highestBlock}
setBlock={(block) => updateNodeBlock(node, block)}
>
<div className="mb-8 break-all">
<div className="mb-8 break-all" data-testid="node">
<Radio
id={`node-url-${index}`}
labelClassName="whitespace-nowrap text-ellipsis overflow-hidden"
@ -158,6 +153,7 @@ export const NodeSwitcher = ({
>
<Input
placeholder="https://"
role="textbox"
value={customNodeText}
hasError={
!!customNodeText &&

View File

@ -121,17 +121,6 @@ beforeEach(() => {
});
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();
});

View File

@ -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<RawEnvironment>;
@ -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);

View File

@ -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);

View File

@ -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<T>(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<string, NodeData>, 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<string, NodeData>, 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<ClientCollection>({});
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);

View File

@ -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;

View File

@ -37,7 +37,6 @@ type NodeCheck<T> = {
export type NodeData = {
url: string;
verified: boolean;
initialized: boolean;
ssl: NodeCheck<boolean>;
block: NodeCheck<number>;

View File

@ -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<string, NodeData>
) => {
@ -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 = (

View File

@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
"types": ["jest", "node", "@testing-library/jest-dom"]
},
"include": [
"**/*.test.ts",