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:
parent
ab0b762cd2
commit
45d05b38f9
@ -1,6 +1,5 @@
|
|||||||
import type { AppProps } from 'next/app';
|
import type { AppProps } from 'next/app';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { Navbar } from '../components/navbar';
|
import { Navbar } from '../components/navbar';
|
||||||
import { t, ThemeContext, useThemeSwitcher } from '@vegaprotocol/react-helpers';
|
import { t, ThemeContext, useThemeSwitcher } from '@vegaprotocol/react-helpers';
|
||||||
import {
|
import {
|
||||||
@ -8,11 +7,7 @@ import {
|
|||||||
VegaManageDialog,
|
VegaManageDialog,
|
||||||
VegaWalletProvider,
|
VegaWalletProvider,
|
||||||
} from '@vegaprotocol/wallet';
|
} from '@vegaprotocol/wallet';
|
||||||
import {
|
import { EnvironmentProvider } from '@vegaprotocol/environment';
|
||||||
useEnvironment,
|
|
||||||
EnvironmentProvider,
|
|
||||||
NetworkSwitcherDialog,
|
|
||||||
} from '@vegaprotocol/environment';
|
|
||||||
import { Connectors } from '../lib/vega-connectors';
|
import { Connectors } from '../lib/vega-connectors';
|
||||||
import { ThemeSwitcher } from '@vegaprotocol/ui-toolkit';
|
import { ThemeSwitcher } from '@vegaprotocol/ui-toolkit';
|
||||||
import { AppLoader } from '../components/app-loader';
|
import { AppLoader } from '../components/app-loader';
|
||||||
@ -21,9 +16,7 @@ import './styles.css';
|
|||||||
import { useGlobalStore } from '../stores';
|
import { useGlobalStore } from '../stores';
|
||||||
|
|
||||||
function AppBody({ Component, pageProps }: AppProps) {
|
function AppBody({ Component, pageProps }: AppProps) {
|
||||||
const { push } = useRouter();
|
|
||||||
const store = useGlobalStore();
|
const store = useGlobalStore();
|
||||||
const { VEGA_NETWORKS } = useEnvironment();
|
|
||||||
const [theme, toggleTheme] = useThemeSwitcher();
|
const [theme, toggleTheme] = useThemeSwitcher();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -61,15 +54,6 @@ function AppBody({ Component, pageProps }: AppProps) {
|
|||||||
dialogOpen={store.vegaWalletManageDialog}
|
dialogOpen={store.vegaWalletManageDialog}
|
||||||
setDialogOpen={(open) => store.setVegaWalletManageDialog(open)}
|
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>
|
</AppLoader>
|
||||||
</div>
|
</div>
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
|
@ -6,4 +6,5 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||||
coverageDirectory: '../../coverage/libs/environment',
|
coverageDirectory: '../../coverage/libs/environment',
|
||||||
|
setupFilesAfterEnv: ['./src/setup-tests.ts'],
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
export * from './network-loader';
|
export * from './network-loader';
|
||||||
export * from './network-switcher';
|
|
||||||
export * from './network-switcher-dialog';
|
|
||||||
export * from './node-switcher';
|
export * from './node-switcher';
|
||||||
export * from './node-switcher-dialog';
|
export * from './node-switcher-dialog';
|
||||||
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -1 +0,0 @@
|
|||||||
export { NetworkSwitcherDialog } from './network-switcher-dialog';
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1 +0,0 @@
|
|||||||
export { NetworkSwitcher } from './network-switcher';
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState } from 'react';
|
||||||
import { t } from '@vegaprotocol/react-helpers';
|
import { t } from '@vegaprotocol/react-helpers';
|
||||||
import {
|
import {
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
@ -55,14 +55,9 @@ export const NodeSwitcher = ({
|
|||||||
const [nodeRadio, setNodeRadio] = useState(
|
const [nodeRadio, setNodeRadio] = useState(
|
||||||
getDefaultNode(config.hosts, VEGA_URL)
|
getDefaultNode(config.hosts, VEGA_URL)
|
||||||
);
|
);
|
||||||
const { state, clients, updateNodeUrl, updateNodeBlock } = useNodes(
|
const { state, clients, updateNodeUrl, updateNodeBlock } = useNodes(config);
|
||||||
VEGA_ENV,
|
const highestBlock = getHighestBlock(VEGA_ENV, state);
|
||||||
config
|
|
||||||
);
|
|
||||||
const highestBlock = useMemo(
|
|
||||||
() => getHighestBlock(VEGA_ENV, state),
|
|
||||||
[VEGA_ENV, state]
|
|
||||||
);
|
|
||||||
const customUrl = state[CUSTOM_NODE_KEY]?.url;
|
const customUrl = state[CUSTOM_NODE_KEY]?.url;
|
||||||
|
|
||||||
const onSubmit = (node: ReturnType<typeof getDefaultNode>) => {
|
const onSubmit = (node: ReturnType<typeof getDefaultNode>) => {
|
||||||
@ -71,12 +66,7 @@ export const NodeSwitcher = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSubmitDisabled = getIsFormDisabled(
|
const isSubmitDisabled = getIsFormDisabled(nodeRadio, VEGA_ENV, state);
|
||||||
nodeRadio,
|
|
||||||
customNodeText,
|
|
||||||
VEGA_ENV,
|
|
||||||
state
|
|
||||||
);
|
|
||||||
|
|
||||||
const customNodeData =
|
const customNodeData =
|
||||||
nodeRadio &&
|
nodeRadio &&
|
||||||
@ -94,7 +84,12 @@ export const NodeSwitcher = ({
|
|||||||
return (
|
return (
|
||||||
<div className="text-black dark:text-white w-full lg:min-w-[800px]">
|
<div className="text-black dark:text-white w-full lg:min-w-[800px]">
|
||||||
<NodeError {...(customNodeError || networkError)} />
|
<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">
|
<p className="text-body-large font-bold mt-16 mb-32">
|
||||||
{t('Select a GraphQL node to connect to:')}
|
{t('Select a GraphQL node to connect to:')}
|
||||||
</p>
|
</p>
|
||||||
@ -124,7 +119,7 @@ export const NodeSwitcher = ({
|
|||||||
highestBlock={highestBlock}
|
highestBlock={highestBlock}
|
||||||
setBlock={(block) => updateNodeBlock(node, block)}
|
setBlock={(block) => updateNodeBlock(node, block)}
|
||||||
>
|
>
|
||||||
<div className="mb-8 break-all">
|
<div className="mb-8 break-all" data-testid="node">
|
||||||
<Radio
|
<Radio
|
||||||
id={`node-url-${index}`}
|
id={`node-url-${index}`}
|
||||||
labelClassName="whitespace-nowrap text-ellipsis overflow-hidden"
|
labelClassName="whitespace-nowrap text-ellipsis overflow-hidden"
|
||||||
@ -158,6 +153,7 @@ export const NodeSwitcher = ({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
placeholder="https://"
|
placeholder="https://"
|
||||||
|
role="textbox"
|
||||||
value={customNodeText}
|
value={customNodeText}
|
||||||
hasError={
|
hasError={
|
||||||
!!customNodeText &&
|
!!customNodeText &&
|
||||||
|
@ -121,17 +121,6 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
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();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ import {
|
|||||||
getIsNodeLoading,
|
getIsNodeLoading,
|
||||||
} from '../utils/validate-node';
|
} from '../utils/validate-node';
|
||||||
import { ErrorType } from '../types';
|
import { ErrorType } from '../types';
|
||||||
import type { Environment, RawEnvironment, NodeData } from '../types';
|
import type { Environment, Networks, RawEnvironment, NodeData } from '../types';
|
||||||
|
|
||||||
type EnvironmentProviderProps = {
|
type EnvironmentProviderProps = {
|
||||||
definitions?: Partial<RawEnvironment>;
|
definitions?: Partial<RawEnvironment>;
|
||||||
@ -26,8 +26,15 @@ export type EnvironmentState = Environment & {
|
|||||||
|
|
||||||
const EnvironmentContext = createContext({} as EnvironmentState);
|
const EnvironmentContext = createContext({} as EnvironmentState);
|
||||||
|
|
||||||
const hasFinishedLoading = (node: NodeData) =>
|
const hasLoaded = (env: Networks, node: NodeData) =>
|
||||||
node.initialized && !getIsNodeLoading(node) && !node.verified;
|
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 = ({
|
export const EnvironmentProvider = ({
|
||||||
definitions,
|
definitions,
|
||||||
@ -47,14 +54,14 @@ export const EnvironmentProvider = ({
|
|||||||
error && console.warn(error.headline);
|
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);
|
const nodeKeys = Object.keys(nodes);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!environment.VEGA_URL) {
|
if (!environment.VEGA_URL) {
|
||||||
const successfulNodeKey = nodeKeys.find(
|
const successfulNodeKey = nodeKeys.find((key) =>
|
||||||
(key) => nodes[key].verified
|
hasLoaded(environment.VEGA_ENV, nodes[key])
|
||||||
) as keyof typeof nodes;
|
);
|
||||||
if (successfulNodeKey && nodes[successfulNodeKey]) {
|
if (successfulNodeKey && nodes[successfulNodeKey]) {
|
||||||
Object.keys(clients).forEach((node) => clients[node]?.stop());
|
Object.keys(clients).forEach((node) => clients[node]?.stop());
|
||||||
updateEnvironment((prevEnvironment) => ({
|
updateEnvironment((prevEnvironment) => ({
|
||||||
@ -81,8 +88,9 @@ export const EnvironmentProvider = ({
|
|||||||
// if the config doesn't contain nodes the app can connect to
|
// if the config doesn't contain nodes the app can connect to
|
||||||
if (
|
if (
|
||||||
nodeKeys.length > 0 &&
|
nodeKeys.length > 0 &&
|
||||||
nodeKeys.filter((key) => hasFinishedLoading(nodes[key])).length ===
|
nodeKeys.filter((key) =>
|
||||||
nodeKeys.length
|
hasFailedLoading(environment.VEGA_ENV, nodes[key])
|
||||||
|
).length === nodeKeys.length
|
||||||
) {
|
) {
|
||||||
Object.keys(clients).forEach((node) => clients[node]?.stop());
|
Object.keys(clients).forEach((node) => clients[node]?.stop());
|
||||||
setNetworkError(ErrorType.CONNECTION_ERROR_ALL);
|
setNetworkError(ErrorType.CONNECTION_ERROR_ALL);
|
||||||
|
@ -2,19 +2,16 @@ import { renderHook, act } from '@testing-library/react-hooks';
|
|||||||
import { ApolloClient } from '@apollo/client';
|
import { ApolloClient } from '@apollo/client';
|
||||||
import createClient from '../utils/apollo-client';
|
import createClient from '../utils/apollo-client';
|
||||||
import { useNodes } from './use-nodes';
|
import { useNodes } from './use-nodes';
|
||||||
import { Networks } from '../types';
|
|
||||||
import createMockClient, {
|
import createMockClient, {
|
||||||
getMockStatisticsResult,
|
getMockStatisticsResult,
|
||||||
} from './mocks/apollo-client';
|
} from './mocks/apollo-client';
|
||||||
|
|
||||||
jest.mock('../utils/apollo-client');
|
jest.mock('../utils/apollo-client');
|
||||||
|
|
||||||
const MOCK_ENV = Networks.DEVNET;
|
|
||||||
const MOCK_DURATION = 1073;
|
const MOCK_DURATION = 1073;
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
url: '',
|
url: '',
|
||||||
verified: false,
|
|
||||||
initialized: false,
|
initialized: false,
|
||||||
responseTime: {
|
responseTime: {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@ -61,7 +58,7 @@ afterAll(() => {
|
|||||||
|
|
||||||
describe('useNodes hook', () => {
|
describe('useNodes hook', () => {
|
||||||
it('returns the default state when empty config provided', () => {
|
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({});
|
expect(result.current.state).toEqual({});
|
||||||
});
|
});
|
||||||
@ -69,13 +66,12 @@ describe('useNodes hook', () => {
|
|||||||
it('sets loading state while waiting for the results', async () => {
|
it('sets loading state while waiting for the results', async () => {
|
||||||
const node = 'https://some.url';
|
const node = 'https://some.url';
|
||||||
const { result, waitForNextUpdate } = renderHook(() =>
|
const { result, waitForNextUpdate } = renderHook(() =>
|
||||||
useNodes(MOCK_ENV, { hosts: [node] })
|
useNodes({ hosts: [node] })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.current.state[node]).toEqual({
|
expect(result.current.state[node]).toEqual({
|
||||||
...initialState,
|
...initialState,
|
||||||
url: node,
|
url: node,
|
||||||
verified: false,
|
|
||||||
initialized: true,
|
initialized: true,
|
||||||
responseTime: {
|
responseTime: {
|
||||||
...initialState.responseTime,
|
...initialState.responseTime,
|
||||||
@ -101,9 +97,7 @@ describe('useNodes hook', () => {
|
|||||||
it('sets statistics results', async () => {
|
it('sets statistics results', async () => {
|
||||||
const mockResult = getMockStatisticsResult();
|
const mockResult = getMockStatisticsResult();
|
||||||
const node = 'https://some.url';
|
const node = 'https://some.url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [node] })
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.state[node].block).toEqual({
|
expect(result.current.state[node].block).toEqual({
|
||||||
@ -128,9 +122,7 @@ describe('useNodes hook', () => {
|
|||||||
|
|
||||||
it('sets subscription result', async () => {
|
it('sets subscription result', async () => {
|
||||||
const node = 'https://some.url';
|
const node = 'https://some.url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [node] })
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.state[node].ssl).toEqual({
|
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 () => {
|
it('sets error when host in not a valid url', async () => {
|
||||||
const node = 'not-url';
|
const node = 'not-url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [node] })
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.state[node].block.hasError).toBe(true);
|
expect(result.current.state[node].block.hasError).toBe(true);
|
||||||
@ -162,9 +152,7 @@ describe('useNodes hook', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const node = 'https://some.url';
|
const node = 'https://some.url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [node] })
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.state[node].block).toEqual({
|
expect(result.current.state[node].block).toEqual({
|
||||||
@ -194,9 +182,7 @@ describe('useNodes hook', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const node = 'https://some.url';
|
const node = 'https://some.url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [node] })
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.state[node].ssl).toEqual({
|
expect(result.current.state[node].ssl).toEqual({
|
||||||
@ -210,9 +196,7 @@ describe('useNodes hook', () => {
|
|||||||
it('allows updating block values', async () => {
|
it('allows updating block values', async () => {
|
||||||
const mockResult = getMockStatisticsResult();
|
const mockResult = getMockStatisticsResult();
|
||||||
const node = 'https://some.url';
|
const node = 'https://some.url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [node] })
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.state[node].block.value).toEqual(
|
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 () => {
|
it('does nothing when calling the block update on a non-existing node', async () => {
|
||||||
const mockResult = getMockStatisticsResult();
|
const mockResult = getMockStatisticsResult();
|
||||||
const node = 'https://some.url';
|
const node = 'https://some.url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [node] })
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.state[node].block.value).toEqual(
|
expect(result.current.state[node].block.value).toEqual(
|
||||||
@ -251,9 +233,7 @@ describe('useNodes hook', () => {
|
|||||||
|
|
||||||
it('adds new node', async () => {
|
it('adds new node', async () => {
|
||||||
const node = 'custom-node-key';
|
const node = 'custom-node-key';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [] })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.state[node]).toEqual(undefined);
|
expect(result.current.state[node]).toEqual(undefined);
|
||||||
|
|
||||||
@ -269,9 +249,7 @@ describe('useNodes hook', () => {
|
|||||||
it('sets new url for node', async () => {
|
it('sets new url for node', async () => {
|
||||||
const node = 'https://some.url';
|
const node = 'https://some.url';
|
||||||
const newUrl = 'https://some-other.url';
|
const newUrl = 'https://some-other.url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [node] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [node] })
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.updateNodeUrl(node, newUrl);
|
result.current.updateNodeUrl(node, newUrl);
|
||||||
@ -285,9 +263,7 @@ describe('useNodes hook', () => {
|
|||||||
it('sets error when custom node has an invalid url', async () => {
|
it('sets error when custom node has an invalid url', async () => {
|
||||||
const node = 'node-key';
|
const node = 'node-key';
|
||||||
const url = 'not-url';
|
const url = 'not-url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [] })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.state[node]).toBe(undefined);
|
expect(result.current.state[node]).toBe(undefined);
|
||||||
|
|
||||||
@ -312,9 +288,7 @@ describe('useNodes hook', () => {
|
|||||||
|
|
||||||
const node = 'node-key';
|
const node = 'node-key';
|
||||||
const url = 'https://some.url';
|
const url = 'https://some.url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [] })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.state[node]).toBe(undefined);
|
expect(result.current.state[node]).toBe(undefined);
|
||||||
|
|
||||||
@ -351,9 +325,7 @@ describe('useNodes hook', () => {
|
|||||||
|
|
||||||
const node = 'node-key';
|
const node = 'node-key';
|
||||||
const url = 'https://some.url';
|
const url = 'https://some.url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [] })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.state[node]).toBe(undefined);
|
expect(result.current.state[node]).toBe(undefined);
|
||||||
|
|
||||||
@ -374,7 +346,7 @@ describe('useNodes hook', () => {
|
|||||||
const url1 = 'https://some.url';
|
const url1 = 'https://some.url';
|
||||||
const url2 = 'https://some-other.url';
|
const url2 = 'https://some-other.url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() =>
|
||||||
useNodes(MOCK_ENV, { hosts: [url1, url2] })
|
useNodes({ hosts: [url1, url2] })
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -386,9 +358,7 @@ describe('useNodes hook', () => {
|
|||||||
it('exposes a client for the custom node', async () => {
|
it('exposes a client for the custom node', async () => {
|
||||||
const node = 'node-key';
|
const node = 'node-key';
|
||||||
const url = 'https://some.url';
|
const url = 'https://some.url';
|
||||||
const { result, waitFor } = renderHook(() =>
|
const { result, waitFor } = renderHook(() => useNodes({ hosts: [] }));
|
||||||
useNodes(MOCK_ENV, { hosts: [] })
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.updateNodeUrl(node, url);
|
result.current.updateNodeUrl(node, url);
|
||||||
|
@ -3,8 +3,7 @@ import { useState, useEffect, useReducer } from 'react';
|
|||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import type createClient from '../utils/apollo-client';
|
import type createClient from '../utils/apollo-client';
|
||||||
import { initializeNode } from '../utils/initialize-node';
|
import { initializeNode } from '../utils/initialize-node';
|
||||||
import { getErrorType, getIsNodeLoading } from '../utils/validate-node';
|
import type { NodeData, Configuration } from '../types';
|
||||||
import type { NodeData, Configuration, Networks } from '../types';
|
|
||||||
|
|
||||||
type StatisticsPayload = {
|
type StatisticsPayload = {
|
||||||
block: NodeData['block']['value'];
|
block: NodeData['block']['value'];
|
||||||
@ -59,7 +58,6 @@ function withError<T>(value?: T) {
|
|||||||
|
|
||||||
const getNodeData = (url?: string): NodeData => ({
|
const getNodeData = (url?: string): NodeData => ({
|
||||||
url: url ?? '',
|
url: url ?? '',
|
||||||
verified: false,
|
|
||||||
initialized: false,
|
initialized: false,
|
||||||
responseTime: withData(),
|
responseTime: withData(),
|
||||||
block: withData(),
|
block: withData(),
|
||||||
@ -108,8 +106,7 @@ const initializeNodes = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const reducer =
|
const reducer = (state: Record<string, NodeData>, action: Action) => {
|
||||||
(env: Networks) => (state: Record<string, NodeData>, action: Action) => {
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ACTIONS.GET_STATISTICS:
|
case ACTIONS.GET_STATISTICS:
|
||||||
return produce(state, (state) => {
|
return produce(state, (state) => {
|
||||||
@ -130,9 +127,6 @@ const reducer =
|
|||||||
state[action.node].responseTime = withData(
|
state[action.node].responseTime = withData(
|
||||||
action.payload?.responseTime
|
action.payload?.responseTime
|
||||||
);
|
);
|
||||||
state[action.node].verified =
|
|
||||||
!getIsNodeLoading(state[action.node]) &&
|
|
||||||
getErrorType(env, state[action.node]) === null;
|
|
||||||
});
|
});
|
||||||
case ACTIONS.GET_STATISTICS_FAILURE:
|
case ACTIONS.GET_STATISTICS_FAILURE:
|
||||||
return produce(state, (state) => {
|
return produce(state, (state) => {
|
||||||
@ -154,9 +148,6 @@ const reducer =
|
|||||||
return produce(state, (state) => {
|
return produce(state, (state) => {
|
||||||
if (!state[action.node]) return;
|
if (!state[action.node]) return;
|
||||||
state[action.node].ssl = withData(true);
|
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:
|
case ACTIONS.CHECK_SUBSCRIPTION_FAILURE:
|
||||||
return produce(state, (state) => {
|
return produce(state, (state) => {
|
||||||
@ -187,9 +178,9 @@ const reducer =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useNodes = (env: Networks, config?: Configuration) => {
|
export const useNodes = (config?: Configuration) => {
|
||||||
const [clients, setClients] = useState<ClientCollection>({});
|
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 configCacheKey = config?.hosts.join(';');
|
||||||
const allUrls = Object.keys(state).map((node) => state[node].url);
|
const allUrls = Object.keys(state).map((node) => state[node].url);
|
||||||
|
|
||||||
|
8
libs/environment/src/setup-tests.ts
Normal file
8
libs/environment/src/setup-tests.ts
Normal 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;
|
@ -37,7 +37,6 @@ type NodeCheck<T> = {
|
|||||||
|
|
||||||
export type NodeData = {
|
export type NodeData = {
|
||||||
url: string;
|
url: string;
|
||||||
verified: boolean;
|
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
ssl: NodeCheck<boolean>;
|
ssl: NodeCheck<boolean>;
|
||||||
block: NodeCheck<number>;
|
block: NodeCheck<number>;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { t } from '@vegaprotocol/react-helpers';
|
import { t } from '@vegaprotocol/react-helpers';
|
||||||
import { CUSTOM_NODE_KEY, ErrorType } from '../types';
|
import { ErrorType } from '../types';
|
||||||
import type { Networks, NodeData } from '../types';
|
import type { Networks, NodeData } from '../types';
|
||||||
|
|
||||||
export const getIsNodeLoading = (node?: NodeData): boolean => {
|
export const getIsNodeLoading = (node?: NodeData): boolean => {
|
||||||
@ -40,7 +40,6 @@ export const getIsNodeDisabled = (env: Networks, data?: NodeData) => {
|
|||||||
|
|
||||||
export const getIsFormDisabled = (
|
export const getIsFormDisabled = (
|
||||||
currentNode: string | undefined,
|
currentNode: string | undefined,
|
||||||
inputText: string,
|
|
||||||
env: Networks,
|
env: Networks,
|
||||||
state: Record<string, NodeData>
|
state: Record<string, NodeData>
|
||||||
) => {
|
) => {
|
||||||
@ -48,16 +47,8 @@ export const getIsFormDisabled = (
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
currentNode === CUSTOM_NODE_KEY &&
|
|
||||||
state[CUSTOM_NODE_KEY] &&
|
|
||||||
inputText !== state[CUSTOM_NODE_KEY].url
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = state[currentNode];
|
const data = state[currentNode];
|
||||||
return getIsNodeDisabled(env, data);
|
return data ? getIsNodeDisabled(env, data) : true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getErrorByType = (
|
export const getErrorByType = (
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "../../dist/out-tsc",
|
"outDir": "../../dist/out-tsc",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"types": ["jest", "node"]
|
"types": ["jest", "node", "@testing-library/jest-dom"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"**/*.test.ts",
|
"**/*.test.ts",
|
||||||
|
Loading…
Reference in New Issue
Block a user