feat(trading): datanode block header for env and node switcher (#2905)
This commit is contained in:
parent
64c92ce91d
commit
a82509f0e0
@ -1,5 +1,5 @@
|
|||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { EnvironmentProvider, NetworkLoader } from '@vegaprotocol/environment';
|
import { NetworkLoader, useInitializeEnv } from '@vegaprotocol/environment';
|
||||||
import { Nav } from './components/nav';
|
import { Nav } from './components/nav';
|
||||||
import { Header } from './components/header';
|
import { Header } from './components/header';
|
||||||
import { Main } from './components/main';
|
import { Main } from './components/main';
|
||||||
@ -61,11 +61,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Wrapper = () => {
|
const Wrapper = () => {
|
||||||
return (
|
useInitializeEnv();
|
||||||
<EnvironmentProvider>
|
return <App />;
|
||||||
<App />
|
|
||||||
</EnvironmentProvider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Wrapper;
|
export default Wrapper;
|
||||||
|
@ -1,39 +1,46 @@
|
|||||||
import { useEnvironment } from '@vegaprotocol/environment';
|
import { NodeSwitcherDialog, useEnvironment } from '@vegaprotocol/environment';
|
||||||
import { t } from '@vegaprotocol/react-helpers';
|
import { t } from '@vegaprotocol/react-helpers';
|
||||||
import { Link } from '@vegaprotocol/ui-toolkit';
|
import { Link } from '@vegaprotocol/ui-toolkit';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
export const Footer = () => {
|
export const Footer = () => {
|
||||||
const { VEGA_URL, GIT_COMMIT_HASH, GIT_ORIGIN_URL, setNodeSwitcherOpen } =
|
const { VEGA_URL, GIT_COMMIT_HASH, GIT_ORIGIN_URL } = useEnvironment();
|
||||||
useEnvironment();
|
const [nodeSwitcherOpen, setNodeSwitcherOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<footer className="grid grid-rows-2 grid-cols-[1fr_auto] text-xs md:text-md md:flex md:col-span-2 px-4 py-2 gap-4 border-t border-neutral-700 dark:border-neutral-300">
|
<>
|
||||||
<div className="flex justify-between gap-2 align-middle">
|
<footer className="grid grid-rows-2 grid-cols-[1fr_auto] text-xs md:text-md md:flex md:col-span-2 px-4 py-2 gap-4 border-t border-neutral-700 dark:border-neutral-300">
|
||||||
<div className="content-center flex border-r border-neutral-700 dark:border-neutral-300 pr-4">
|
<div className="flex justify-between gap-2 align-middle">
|
||||||
{GIT_COMMIT_HASH && (
|
<div className="content-center flex border-r border-neutral-700 dark:border-neutral-300 pr-4">
|
||||||
<p data-testid="git-commit-hash">
|
{GIT_COMMIT_HASH && (
|
||||||
{t('Version')}:{' '}
|
<p data-testid="git-commit-hash">
|
||||||
<Link
|
{t('Version')}:{' '}
|
||||||
href={
|
<Link
|
||||||
GIT_ORIGIN_URL
|
href={
|
||||||
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
|
GIT_ORIGIN_URL
|
||||||
: undefined
|
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
|
||||||
}
|
: undefined
|
||||||
target={GIT_ORIGIN_URL ? '_blank' : undefined}
|
}
|
||||||
>
|
target={GIT_ORIGIN_URL ? '_blank' : undefined}
|
||||||
{GIT_COMMIT_HASH}
|
>
|
||||||
</Link>
|
{GIT_COMMIT_HASH}
|
||||||
</p>
|
</Link>
|
||||||
)}
|
</p>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex pl-2 content-center">
|
<div className="flex pl-2 content-center">
|
||||||
{VEGA_URL && <NodeUrl url={VEGA_URL} />}
|
{VEGA_URL && <NodeUrl url={VEGA_URL} />}
|
||||||
<Link className="ml-2" onClick={setNodeSwitcherOpen}>
|
<Link className="ml-2" onClick={() => setNodeSwitcherOpen(true)}>
|
||||||
{t('Change')}
|
{t('Change')}
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</footer>
|
||||||
</footer>
|
<NodeSwitcherDialog
|
||||||
|
open={nodeSwitcherOpen}
|
||||||
|
setOpen={setNodeSwitcherOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import type { InMemoryCacheConfig } from '@apollo/client';
|
||||||
|
import { NetworkLoader, useInitializeEnv } from '@vegaprotocol/environment';
|
||||||
import { useRoutes } from 'react-router-dom';
|
import { useRoutes } from 'react-router-dom';
|
||||||
|
|
||||||
import '../styles.scss';
|
import '../styles.scss';
|
||||||
@ -5,14 +7,40 @@ import { Navbar } from './components/navbar';
|
|||||||
|
|
||||||
import { routerConfig } from './routes/router-config';
|
import { routerConfig } from './routes/router-config';
|
||||||
|
|
||||||
|
const cache: InMemoryCacheConfig = {
|
||||||
|
typePolicies: {
|
||||||
|
Market: {
|
||||||
|
merge: true,
|
||||||
|
},
|
||||||
|
Party: {
|
||||||
|
merge: true,
|
||||||
|
},
|
||||||
|
Query: {},
|
||||||
|
Account: {
|
||||||
|
keyFields: false,
|
||||||
|
fields: {
|
||||||
|
balanceFormatted: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Node: {
|
||||||
|
keyFields: false,
|
||||||
|
},
|
||||||
|
Instrument: {
|
||||||
|
keyFields: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
const AppRouter = () => useRoutes(routerConfig);
|
const AppRouter = () => useRoutes(routerConfig);
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
useInitializeEnv();
|
||||||
return (
|
return (
|
||||||
<div className="max-h-full min-h-full bg-white">
|
<NetworkLoader cache={cache}>
|
||||||
<Navbar />
|
<div className="max-h-full min-h-full bg-white">
|
||||||
<AppRouter />
|
<Navbar />
|
||||||
</div>
|
<AppRouter />
|
||||||
|
</div>
|
||||||
|
</NetworkLoader>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,44 +1,15 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { EnvironmentProvider, NetworkLoader } from '@vegaprotocol/environment';
|
|
||||||
|
|
||||||
import App from './app/app';
|
import App from './app/app';
|
||||||
import type { InMemoryCacheConfig } from '@apollo/client';
|
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
const root = rootElement && createRoot(rootElement);
|
const root = rootElement && createRoot(rootElement);
|
||||||
const cache: InMemoryCacheConfig = {
|
|
||||||
typePolicies: {
|
|
||||||
Market: {
|
|
||||||
merge: true,
|
|
||||||
},
|
|
||||||
Party: {
|
|
||||||
merge: true,
|
|
||||||
},
|
|
||||||
Query: {},
|
|
||||||
Account: {
|
|
||||||
keyFields: false,
|
|
||||||
fields: {
|
|
||||||
balanceFormatted: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Node: {
|
|
||||||
keyFields: false,
|
|
||||||
},
|
|
||||||
Instrument: {
|
|
||||||
keyFields: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
root?.render(
|
root?.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<EnvironmentProvider>
|
<App />
|
||||||
<NetworkLoader cache={cache}>
|
|
||||||
<App />
|
|
||||||
</NetworkLoader>
|
|
||||||
</EnvironmentProvider>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
@ -2,3 +2,4 @@ NX_VEGA_URL=https://api.stagnet3.vega.xyz/graphql
|
|||||||
NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/stagnet3-network.json
|
NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/stagnet3-network.json
|
||||||
NX_VEGA_NETWORKS='{"TESTNET":"https://multisig-signer.fairground.wtf","MAINNET":"https://multisig-signer.vega.xyz"}'
|
NX_VEGA_NETWORKS='{"TESTNET":"https://multisig-signer.fairground.wtf","MAINNET":"https://multisig-signer.vega.xyz"}'
|
||||||
NX_VEGA_ENV=STAGNET3
|
NX_VEGA_ENV=STAGNET3
|
||||||
|
|
||||||
|
@ -3,9 +3,9 @@ import classnames from 'classnames';
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { BrowserTracing } from '@sentry/tracing';
|
import { BrowserTracing } from '@sentry/tracing';
|
||||||
import {
|
import {
|
||||||
EnvironmentProvider,
|
|
||||||
NetworkLoader,
|
NetworkLoader,
|
||||||
useEnvironment,
|
useEnvironment,
|
||||||
|
useInitializeEnv,
|
||||||
} from '@vegaprotocol/environment';
|
} from '@vegaprotocol/environment';
|
||||||
import { AsyncRenderer, Button, Lozenge } from '@vegaprotocol/ui-toolkit';
|
import { AsyncRenderer, Button, Lozenge } from '@vegaprotocol/ui-toolkit';
|
||||||
import type { EthereumConfig } from '@vegaprotocol/web3';
|
import type { EthereumConfig } from '@vegaprotocol/web3';
|
||||||
@ -64,6 +64,7 @@ function App() {
|
|||||||
environment: VEGA_ENV,
|
environment: VEGA_ENV,
|
||||||
});
|
});
|
||||||
}, [VEGA_ENV]);
|
}, [VEGA_ENV]);
|
||||||
|
|
||||||
const Connectors = useMemo(() => {
|
const Connectors = useMemo(() => {
|
||||||
if (config?.chain_id) {
|
if (config?.chain_id) {
|
||||||
return createConnectors(ETHEREUM_PROVIDER_URL, Number(config.chain_id));
|
return createConnectors(ETHEREUM_PROVIDER_URL, Number(config.chain_id));
|
||||||
@ -107,12 +108,11 @@ const Wrapper = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
useInitializeEnv();
|
||||||
return (
|
return (
|
||||||
<EnvironmentProvider>
|
<NetworkLoader cache={cache}>
|
||||||
<NetworkLoader cache={cache}>
|
<App />
|
||||||
<App />
|
</NetworkLoader>
|
||||||
</NetworkLoader>
|
|
||||||
</EnvironmentProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -24,8 +24,8 @@ import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
|||||||
import { useEthereumConfig } from '@vegaprotocol/web3';
|
import { useEthereumConfig } from '@vegaprotocol/web3';
|
||||||
import {
|
import {
|
||||||
useEnvironment,
|
useEnvironment,
|
||||||
EnvironmentProvider,
|
|
||||||
NetworkLoader,
|
NetworkLoader,
|
||||||
|
useInitializeEnv,
|
||||||
} from '@vegaprotocol/environment';
|
} from '@vegaprotocol/environment';
|
||||||
import { createConnectors } from './lib/web3-connectors';
|
import { createConnectors } from './lib/web3-connectors';
|
||||||
import { ENV } from './config/env';
|
import { ENV } from './config/env';
|
||||||
@ -166,12 +166,12 @@ const AppContainer = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
useInitializeEnv();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvironmentProvider>
|
<NetworkLoader cache={cache}>
|
||||||
<NetworkLoader cache={cache}>
|
<AppContainer />
|
||||||
<AppContainer />
|
</NetworkLoader>
|
||||||
</NetworkLoader>
|
|
||||||
</EnvironmentProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,23 +1,18 @@
|
|||||||
import { render, screen, within } from '@testing-library/react';
|
import { render, screen, within } from '@testing-library/react';
|
||||||
import { EnvironmentProvider, Networks } from '@vegaprotocol/environment';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { Nav } from './nav';
|
import { Nav } from './nav';
|
||||||
|
|
||||||
jest.mock('@vegaprotocol/environment', () => ({
|
jest.mock('@vegaprotocol/environment', () => ({
|
||||||
...jest.requireActual('@vegaprotocol/environment'),
|
...jest.requireActual('@vegaprotocol/environment'),
|
||||||
NetworkSwitcher: () => <div data-testid="network-switcher" />,
|
NetworkSwitcher: () => <div data-testid="network-switcher" />,
|
||||||
|
useEnvironment: () => ({ VEGA_ENV: 'MAINNET' }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderComponent = (initialEntries?: string[]) => {
|
const renderComponent = (initialEntries?: string[]) => {
|
||||||
return render(
|
return render(
|
||||||
<EnvironmentProvider
|
<MemoryRouter initialEntries={initialEntries}>
|
||||||
definitions={{ VEGA_ENV: Networks.MAINNET }}
|
<Nav />
|
||||||
config={{ hosts: [] }}
|
</MemoryRouter>
|
||||||
>
|
|
||||||
<MemoryRouter initialEntries={initialEntries}>
|
|
||||||
<Nav />
|
|
||||||
</MemoryRouter>
|
|
||||||
</EnvironmentProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -145,8 +145,9 @@ describe('markets table', { tags: '@smoke' }, () => {
|
|||||||
proposal: { terms: { enactmentDatetime: '2023-01-31 12:00:01' } },
|
proposal: { terms: { enactmentDatetime: '2023-01-31 12:00:01' } },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
cy.visit('/');
|
|
||||||
cy.visit('#/markets/market-0');
|
cy.visit('#/markets/market-0');
|
||||||
|
cy.url().should('contain', 'market-0');
|
||||||
|
cy.getByTestId('dialog-close').click();
|
||||||
cy.getByTestId('item-value').contains('Opening auction').realHover();
|
cy.getByTestId('item-value').contains('Opening auction').realHover();
|
||||||
cy.getByTestId('opening-auction-sub-status').should(
|
cy.getByTestId('opening-auction-sub-status').should(
|
||||||
'contain.text',
|
'contain.text',
|
||||||
@ -155,8 +156,7 @@ describe('markets table', { tags: '@smoke' }, () => {
|
|||||||
|
|
||||||
const now = new Date(Date.parse('2023-01-30 12:00:01')).getTime();
|
const now = new Date(Date.parse('2023-01-30 12:00:01')).getTime();
|
||||||
cy.clock(now, ['Date']); // Set "now" to BEFORE reservation
|
cy.clock(now, ['Date']); // Set "now" to BEFORE reservation
|
||||||
cy.visit('/');
|
cy.reload();
|
||||||
cy.visit('#/markets/market-0');
|
|
||||||
cy.getByTestId('item-value').contains('Opening auction').realHover();
|
cy.getByTestId('item-value').contains('Opening auction').realHover();
|
||||||
cy.getByTestId('opening-auction-sub-status').should(
|
cy.getByTestId('opening-auction-sub-status').should(
|
||||||
'contain.text',
|
'contain.text',
|
||||||
|
30
apps/trading/components/app-loader/app-failure.tsx
Normal file
30
apps/trading/components/app-loader/app-failure.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { t } from '@vegaprotocol/react-helpers';
|
||||||
|
import { Button } from '@vegaprotocol/ui-toolkit';
|
||||||
|
import { useGlobalStore } from '../../stores';
|
||||||
|
|
||||||
|
export const AppFailure = ({
|
||||||
|
title,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
error?: string | null;
|
||||||
|
}) => {
|
||||||
|
const { setNodeSwitcher } = useGlobalStore((store) => ({
|
||||||
|
nodeSwitcherOpen: store.nodeSwitcherDialog,
|
||||||
|
setNodeSwitcher: (open: boolean) =>
|
||||||
|
store.update({ nodeSwitcherDialog: open }),
|
||||||
|
}));
|
||||||
|
const nonIdealWrapperClasses =
|
||||||
|
'h-full min-h-screen flex items-center justify-center';
|
||||||
|
return (
|
||||||
|
<div className={nonIdealWrapperClasses}>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-xl mb-4">{title}</h1>
|
||||||
|
{error && <p className="text-sm mb-8">{error}</p>}
|
||||||
|
<Button onClick={() => setNodeSwitcher(true)}>
|
||||||
|
{t('Change node')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
96
apps/trading/components/app-loader/app-loader.tsx
Normal file
96
apps/trading/components/app-loader/app-loader.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { InMemoryCacheConfig } from '@apollo/client';
|
||||||
|
import {
|
||||||
|
NetworkLoader,
|
||||||
|
NodeGuard,
|
||||||
|
useEnvironment,
|
||||||
|
} from '@vegaprotocol/environment';
|
||||||
|
import { t } from '@vegaprotocol/react-helpers';
|
||||||
|
import { MaintenancePage } from '@vegaprotocol/ui-toolkit';
|
||||||
|
import { VegaWalletProvider } from '@vegaprotocol/wallet';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { AppFailure } from './app-failure';
|
||||||
|
import { Web3Provider } from './web3-provider';
|
||||||
|
|
||||||
|
const DynamicLoader = dynamic(() => import('../preloader/preloader'), {
|
||||||
|
loading: () => <>Loading...</>,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AppLoader = ({ children }: { children: ReactNode }) => {
|
||||||
|
const { error, VEGA_URL, MAINTENANCE_PAGE } = useEnvironment((store) => ({
|
||||||
|
error: store.error,
|
||||||
|
VEGA_URL: store.VEGA_URL,
|
||||||
|
MAINTENANCE_PAGE: store.MAINTENANCE_PAGE,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (MAINTENANCE_PAGE) {
|
||||||
|
return <MaintenancePage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NetworkLoader
|
||||||
|
cache={cacheConfig}
|
||||||
|
skeleton={<DynamicLoader />}
|
||||||
|
failure={
|
||||||
|
<AppFailure title={t('Could not initialize app')} error={error} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<NodeGuard
|
||||||
|
skeleton={<DynamicLoader />}
|
||||||
|
failure={<AppFailure title={t(`Node: ${VEGA_URL} is unsuitable`)} />}
|
||||||
|
>
|
||||||
|
<Web3Provider>
|
||||||
|
<VegaWalletProvider>{children}</VegaWalletProvider>
|
||||||
|
</Web3Provider>
|
||||||
|
</NodeGuard>
|
||||||
|
</NetworkLoader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheConfig: InMemoryCacheConfig = {
|
||||||
|
typePolicies: {
|
||||||
|
Account: {
|
||||||
|
keyFields: false,
|
||||||
|
fields: {
|
||||||
|
balanceFormatted: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Instrument: {
|
||||||
|
keyFields: false,
|
||||||
|
},
|
||||||
|
TradableInstrument: {
|
||||||
|
keyFields: ['instrument'],
|
||||||
|
},
|
||||||
|
Product: {
|
||||||
|
keyFields: ['settlementAsset', ['id']],
|
||||||
|
},
|
||||||
|
MarketData: {
|
||||||
|
keyFields: ['market', ['id']],
|
||||||
|
},
|
||||||
|
Node: {
|
||||||
|
keyFields: false,
|
||||||
|
},
|
||||||
|
Withdrawal: {
|
||||||
|
fields: {
|
||||||
|
pendingOnForeignChain: {
|
||||||
|
read: (isPending = false) => isPending,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ERC20: {
|
||||||
|
keyFields: ['contractAddress'],
|
||||||
|
},
|
||||||
|
PositionUpdate: {
|
||||||
|
keyFields: false,
|
||||||
|
},
|
||||||
|
AccountUpdate: {
|
||||||
|
keyFields: false,
|
||||||
|
},
|
||||||
|
Party: {
|
||||||
|
keyFields: false,
|
||||||
|
},
|
||||||
|
Fees: {
|
||||||
|
keyFields: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
@ -1,121 +1,3 @@
|
|||||||
import type { ReactNode } from 'react';
|
export * from './app-failure';
|
||||||
import { useEffect } from 'react';
|
export * from './app-loader';
|
||||||
import { NetworkLoader, useEnvironment } from '@vegaprotocol/environment';
|
export * from './web3-provider';
|
||||||
import type { InMemoryCacheConfig } from '@apollo/client';
|
|
||||||
import {
|
|
||||||
useEthereumConfig,
|
|
||||||
createConnectors,
|
|
||||||
Web3Provider as Web3ProviderInternal,
|
|
||||||
useWeb3ConnectStore,
|
|
||||||
} from '@vegaprotocol/web3';
|
|
||||||
import { AsyncRenderer, Loader } from '@vegaprotocol/ui-toolkit';
|
|
||||||
|
|
||||||
interface AppLoaderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component to handle any app initialization, startup queries and other things
|
|
||||||
* that must happen for it can be used
|
|
||||||
*/
|
|
||||||
export function AppLoader({ children }: AppLoaderProps) {
|
|
||||||
return (
|
|
||||||
<NetworkLoader skeleton={<Loader />} cache={cacheConfig}>
|
|
||||||
{children}
|
|
||||||
</NetworkLoader>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Web3Provider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const { config, loading, error } = useEthereumConfig();
|
|
||||||
const { ETHEREUM_PROVIDER_URL, ETH_LOCAL_PROVIDER_URL, ETH_WALLET_MNEMONIC } =
|
|
||||||
useEnvironment();
|
|
||||||
const [connectors, initializeConnectors] = useWeb3ConnectStore((store) => [
|
|
||||||
store.connectors,
|
|
||||||
store.initialize,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (config?.chain_id) {
|
|
||||||
return initializeConnectors(
|
|
||||||
createConnectors(
|
|
||||||
ETHEREUM_PROVIDER_URL,
|
|
||||||
Number(config?.chain_id),
|
|
||||||
ETH_LOCAL_PROVIDER_URL,
|
|
||||||
ETH_WALLET_MNEMONIC
|
|
||||||
),
|
|
||||||
Number(config.chain_id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
config?.chain_id,
|
|
||||||
ETHEREUM_PROVIDER_URL,
|
|
||||||
initializeConnectors,
|
|
||||||
ETH_LOCAL_PROVIDER_URL,
|
|
||||||
ETH_WALLET_MNEMONIC,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AsyncRenderer
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
data={connectors}
|
|
||||||
noDataCondition={(d) => {
|
|
||||||
if (!d) return true;
|
|
||||||
return d.length < 1;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Web3ProviderInternal connectors={connectors}>
|
|
||||||
<>{children}</>
|
|
||||||
</Web3ProviderInternal>
|
|
||||||
</AsyncRenderer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cacheConfig: InMemoryCacheConfig = {
|
|
||||||
typePolicies: {
|
|
||||||
Account: {
|
|
||||||
keyFields: false,
|
|
||||||
fields: {
|
|
||||||
balanceFormatted: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Instrument: {
|
|
||||||
keyFields: false,
|
|
||||||
},
|
|
||||||
TradableInstrument: {
|
|
||||||
keyFields: ['instrument'],
|
|
||||||
},
|
|
||||||
Product: {
|
|
||||||
keyFields: ['settlementAsset', ['id']],
|
|
||||||
},
|
|
||||||
MarketData: {
|
|
||||||
keyFields: ['market', ['id']],
|
|
||||||
},
|
|
||||||
Node: {
|
|
||||||
keyFields: false,
|
|
||||||
},
|
|
||||||
Withdrawal: {
|
|
||||||
fields: {
|
|
||||||
pendingOnForeignChain: {
|
|
||||||
read: (isPending = false) => isPending,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ERC20: {
|
|
||||||
keyFields: ['contractAddress'],
|
|
||||||
},
|
|
||||||
PositionUpdate: {
|
|
||||||
keyFields: false,
|
|
||||||
},
|
|
||||||
AccountUpdate: {
|
|
||||||
keyFields: false,
|
|
||||||
},
|
|
||||||
Party: {
|
|
||||||
keyFields: false,
|
|
||||||
},
|
|
||||||
Fees: {
|
|
||||||
keyFields: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
58
apps/trading/components/app-loader/web3-provider.tsx
Normal file
58
apps/trading/components/app-loader/web3-provider.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
useEthereumConfig,
|
||||||
|
createConnectors,
|
||||||
|
Web3Provider as Web3ProviderInternal,
|
||||||
|
useWeb3ConnectStore,
|
||||||
|
} from '@vegaprotocol/web3';
|
||||||
|
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||||
|
import { t } from '@vegaprotocol/react-helpers';
|
||||||
|
import { useEnvironment } from '@vegaprotocol/environment';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export const Web3Provider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const { config, loading, error } = useEthereumConfig();
|
||||||
|
const { ETHEREUM_PROVIDER_URL, ETH_LOCAL_PROVIDER_URL, ETH_WALLET_MNEMONIC } =
|
||||||
|
useEnvironment();
|
||||||
|
const [connectors, initializeConnectors] = useWeb3ConnectStore((store) => [
|
||||||
|
store.connectors,
|
||||||
|
store.initialize,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.chain_id) {
|
||||||
|
return initializeConnectors(
|
||||||
|
createConnectors(
|
||||||
|
ETHEREUM_PROVIDER_URL,
|
||||||
|
Number(config?.chain_id),
|
||||||
|
ETH_LOCAL_PROVIDER_URL,
|
||||||
|
ETH_WALLET_MNEMONIC
|
||||||
|
),
|
||||||
|
Number(config.chain_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
config?.chain_id,
|
||||||
|
ETHEREUM_PROVIDER_URL,
|
||||||
|
initializeConnectors,
|
||||||
|
ETH_LOCAL_PROVIDER_URL,
|
||||||
|
ETH_WALLET_MNEMONIC,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncRenderer
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
data={connectors}
|
||||||
|
noDataCondition={(d) => {
|
||||||
|
if (!d) return true;
|
||||||
|
return d.length < 1;
|
||||||
|
}}
|
||||||
|
noDataMessage={t('Could not fetch Ethereum configuration')}
|
||||||
|
>
|
||||||
|
<Web3ProviderInternal connectors={connectors}>
|
||||||
|
<>{children}</>
|
||||||
|
</Web3ProviderInternal>
|
||||||
|
</AsyncRenderer>
|
||||||
|
);
|
||||||
|
};
|
@ -1,57 +1,38 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { Footer, NodeHealth } from './footer';
|
import { NodeUrl, NodeHealth } from './footer';
|
||||||
import { useEnvironment } from '@vegaprotocol/environment';
|
|
||||||
|
|
||||||
jest.mock('@vegaprotocol/environment');
|
describe('NodeUrl', () => {
|
||||||
|
|
||||||
describe('Footer', () => {
|
|
||||||
it('can open node switcher by clicking the node url', () => {
|
it('can open node switcher by clicking the node url', () => {
|
||||||
const mockOpenNodeSwitcher = jest.fn();
|
const mockOpenNodeSwitcher = jest.fn();
|
||||||
const node = 'n99.somenetwork.vega.xyz';
|
const node = 'https://api.n99.somenetwork.vega.xyz';
|
||||||
|
|
||||||
// @ts-ignore mock env hook
|
render(<NodeUrl url={node} openNodeSwitcher={mockOpenNodeSwitcher} />);
|
||||||
useEnvironment.mockImplementation(() => ({
|
|
||||||
VEGA_URL: `https://api.${node}/graphql`,
|
|
||||||
blockDifference: 0,
|
|
||||||
setNodeSwitcherOpen: mockOpenNodeSwitcher,
|
|
||||||
}));
|
|
||||||
|
|
||||||
render(<Footer />);
|
fireEvent.click(screen.getByText(/n99/));
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(node));
|
|
||||||
expect(mockOpenNodeSwitcher).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can open node switcher by clicking health', () => {
|
|
||||||
const mockOpenNodeSwitcher = jest.fn();
|
|
||||||
const node = 'n99.somenetwork.vega.xyz';
|
|
||||||
|
|
||||||
// @ts-ignore mock env hook
|
|
||||||
useEnvironment.mockImplementation(() => ({
|
|
||||||
VEGA_URL: `https://api.${node}/graphql`,
|
|
||||||
blockDifference: 0,
|
|
||||||
setNodeSwitcherOpen: mockOpenNodeSwitcher,
|
|
||||||
}));
|
|
||||||
|
|
||||||
render(<Footer />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Operational'));
|
|
||||||
expect(mockOpenNodeSwitcher).toHaveBeenCalled();
|
expect(mockOpenNodeSwitcher).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('NodeHealth', () => {
|
describe('NodeHealth', () => {
|
||||||
|
const mockOpenNodeSwitcher = jest.fn();
|
||||||
const cases = [
|
const cases = [
|
||||||
{ diff: 0, classname: 'bg-vega-green-550', text: 'Operational' },
|
{ diff: 0, classname: 'bg-vega-green-550', text: 'Operational' },
|
||||||
{ diff: 5, classname: 'bg-warning', text: '5 Blocks behind' },
|
{ diff: 5, classname: 'bg-warning', text: '5 Blocks behind' },
|
||||||
{ diff: -1, classname: 'bg-danger', text: 'Non operational' },
|
{ diff: null, classname: 'bg-danger', text: 'Non operational' },
|
||||||
];
|
];
|
||||||
it.each(cases)(
|
it.each(cases)(
|
||||||
'renders correct text and indicator color for $diff block difference',
|
'renders correct text and indicator color for $diff block difference',
|
||||||
(elem) => {
|
(elem) => {
|
||||||
render(<NodeHealth blockDiff={elem.diff} openNodeSwitcher={jest.fn()} />);
|
render(
|
||||||
|
<NodeHealth
|
||||||
|
blockDiff={elem.diff}
|
||||||
|
openNodeSwitcher={mockOpenNodeSwitcher}
|
||||||
|
/>
|
||||||
|
);
|
||||||
expect(screen.getByTestId('indicator')).toHaveClass(elem.classname);
|
expect(screen.getByTestId('indicator')).toHaveClass(elem.classname);
|
||||||
expect(screen.getByText(elem.text)).toBeInTheDocument();
|
expect(screen.getByText(elem.text)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText(elem.text));
|
||||||
|
expect(mockOpenNodeSwitcher).toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
import { useEnvironment } from '@vegaprotocol/environment';
|
import { useEnvironment, useNodeHealth } from '@vegaprotocol/environment';
|
||||||
import { t, useNavigatorOnline } from '@vegaprotocol/react-helpers';
|
import { t, useNavigatorOnline } from '@vegaprotocol/react-helpers';
|
||||||
import { ButtonLink, Indicator, Intent } from '@vegaprotocol/ui-toolkit';
|
import { ButtonLink, Indicator, Intent } from '@vegaprotocol/ui-toolkit';
|
||||||
|
import { useGlobalStore } from '../../stores';
|
||||||
|
|
||||||
export const Footer = () => {
|
export const Footer = () => {
|
||||||
const { VEGA_URL, blockDifference, setNodeSwitcherOpen } = useEnvironment();
|
const { VEGA_URL } = useEnvironment();
|
||||||
|
const setNodeSwitcher = useGlobalStore(
|
||||||
|
(store) => (open: boolean) => store.update({ nodeSwitcherDialog: open })
|
||||||
|
);
|
||||||
|
const { blockDiff } = useNodeHealth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="px-4 py-1 text-xs border-t border-default">
|
<footer className="px-4 py-1 text-xs border-t border-default">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@ -11,11 +17,14 @@ export const Footer = () => {
|
|||||||
{VEGA_URL && (
|
{VEGA_URL && (
|
||||||
<>
|
<>
|
||||||
<NodeHealth
|
<NodeHealth
|
||||||
blockDiff={blockDifference}
|
blockDiff={blockDiff}
|
||||||
openNodeSwitcher={setNodeSwitcherOpen}
|
openNodeSwitcher={() => setNodeSwitcher(true)}
|
||||||
/>
|
/>
|
||||||
{' | '}
|
{' | '}
|
||||||
<NodeUrl url={VEGA_URL} openNodeSwitcher={setNodeSwitcherOpen} />
|
<NodeUrl
|
||||||
|
url={VEGA_URL}
|
||||||
|
openNodeSwitcher={() => setNodeSwitcher(true)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -29,7 +38,7 @@ interface NodeUrlProps {
|
|||||||
openNodeSwitcher: () => void;
|
openNodeSwitcher: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => {
|
export const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => {
|
||||||
// get base url from api url, api sub domain
|
// get base url from api url, api sub domain
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const nodeUrl = urlObj.origin.replace(/^[^.]+\./g, '');
|
const nodeUrl = urlObj.origin.replace(/^[^.]+\./g, '');
|
||||||
@ -38,7 +47,7 @@ const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => {
|
|||||||
|
|
||||||
interface NodeHealthProps {
|
interface NodeHealthProps {
|
||||||
openNodeSwitcher: () => void;
|
openNodeSwitcher: () => void;
|
||||||
blockDiff: number;
|
blockDiff: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// How many blocks behind the most advanced block that is
|
// How many blocks behind the most advanced block that is
|
||||||
@ -57,7 +66,7 @@ export const NodeHealth = ({
|
|||||||
if (!online) {
|
if (!online) {
|
||||||
text = t('Offline');
|
text = t('Offline');
|
||||||
intent = Intent.Danger;
|
intent = Intent.Danger;
|
||||||
} else if (blockDiff < 0) {
|
} else if (blockDiff === null) {
|
||||||
// Block height query failed and null was returned
|
// Block height query failed and null was returned
|
||||||
text = t('Non operational');
|
text = t('Non operational');
|
||||||
intent = Intent.Danger;
|
intent = Intent.Danger;
|
||||||
@ -67,9 +76,9 @@ export const NodeHealth = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span>
|
<>
|
||||||
<Indicator variant={intent} />
|
<Indicator variant={intent} />
|
||||||
<ButtonLink onClick={openNodeSwitcher}>{text}</ButtonLink>
|
<ButtonLink onClick={openNodeSwitcher}>{text}</ButtonLink>
|
||||||
</span>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import { MockedProvider } from '@apollo/client/testing';
|
import { MockedProvider } from '@apollo/client/testing';
|
||||||
import { Networks, EnvironmentProvider } from '@vegaprotocol/environment';
|
import { Networks, useEnvironment } from '@vegaprotocol/environment';
|
||||||
import { RiskNoticeDialog } from './risk-notice-dialog';
|
import { RiskNoticeDialog } from './risk-notice-dialog';
|
||||||
import { WelcomeDialog } from './welcome-dialog';
|
import { WelcomeDialog } from './welcome-dialog';
|
||||||
|
|
||||||
|
jest.mock('@vegaprotocol/environment');
|
||||||
|
|
||||||
const mockEnvDefinitions = {
|
const mockEnvDefinitions = {
|
||||||
VEGA_CONFIG_URL: 'https://config.url',
|
VEGA_CONFIG_URL: 'https://config.url',
|
||||||
VEGA_URL: 'https://test.url',
|
VEGA_URL: 'https://test.url',
|
||||||
@ -28,15 +30,16 @@ describe('Risk notice dialog', () => {
|
|||||||
`(
|
`(
|
||||||
'$assertion the risk notice on $network',
|
'$assertion the risk notice on $network',
|
||||||
async ({ assertion, network }) => {
|
async ({ assertion, network }) => {
|
||||||
|
// @ts-ignore ignore mock implementation
|
||||||
|
useEnvironment.mockImplementation(() => ({
|
||||||
|
...mockEnvDefinitions,
|
||||||
|
VEGA_ENV: network,
|
||||||
|
}));
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<EnvironmentProvider
|
<MockedProvider>
|
||||||
definitions={{ ...mockEnvDefinitions, VEGA_ENV: network }}
|
<WelcomeDialog />
|
||||||
config={{ hosts: [] }}
|
</MockedProvider>,
|
||||||
>
|
|
||||||
<MockedProvider>
|
|
||||||
<WelcomeDialog />
|
|
||||||
</MockedProvider>
|
|
||||||
</EnvironmentProvider>,
|
|
||||||
{ wrapper: BrowserRouter }
|
{ wrapper: BrowserRouter }
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -51,14 +54,13 @@ describe('Risk notice dialog', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it("doesn't display the risk notice when previously acknowledged", () => {
|
it("doesn't display the risk notice when previously acknowledged", () => {
|
||||||
render(
|
// @ts-ignore ignore mock implementation
|
||||||
<EnvironmentProvider
|
useEnvironment.mockImplementation(() => ({
|
||||||
definitions={{ ...mockEnvDefinitions, VEGA_ENV: Networks.MAINNET }}
|
...mockEnvDefinitions,
|
||||||
config={{ hosts: [] }}
|
VEGA_ENV: Networks.MAINNET,
|
||||||
>
|
}));
|
||||||
<RiskNoticeDialog onClose={mockOnClose} />
|
|
||||||
</EnvironmentProvider>
|
render(<RiskNoticeDialog onClose={mockOnClose} />);
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByText(introText)).toBeInTheDocument();
|
expect(screen.queryByText(introText)).toBeInTheDocument();
|
||||||
|
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import type { AppProps } from 'next/app';
|
import type { AppProps } from 'next/app';
|
||||||
import { Navbar } from '../components/navbar';
|
import { Navbar } from '../components/navbar';
|
||||||
import { t } from '@vegaprotocol/react-helpers';
|
import { t } from '@vegaprotocol/react-helpers';
|
||||||
import {
|
import {
|
||||||
useEagerConnect as useVegaEagerConnect,
|
useEagerConnect as useVegaEagerConnect,
|
||||||
VegaWalletProvider,
|
|
||||||
useVegaTransactionManager,
|
useVegaTransactionManager,
|
||||||
useVegaTransactionUpdater,
|
useVegaTransactionUpdater,
|
||||||
useVegaWallet,
|
useVegaWallet,
|
||||||
@ -17,17 +15,17 @@ import {
|
|||||||
useEthWithdrawApprovalsManager,
|
useEthWithdrawApprovalsManager,
|
||||||
} from '@vegaprotocol/web3';
|
} from '@vegaprotocol/web3';
|
||||||
import {
|
import {
|
||||||
EnvironmentProvider,
|
|
||||||
envTriggerMapping,
|
envTriggerMapping,
|
||||||
Networks,
|
Networks,
|
||||||
|
NodeSwitcherDialog,
|
||||||
useEnvironment,
|
useEnvironment,
|
||||||
|
useInitializeEnv,
|
||||||
} from '@vegaprotocol/environment';
|
} from '@vegaprotocol/environment';
|
||||||
import { AppLoader, Web3Provider } from '../components/app-loader';
|
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import './gen-styles.scss';
|
import './gen-styles.scss';
|
||||||
import { usePageTitleStore } from '../stores';
|
import { useGlobalStore, usePageTitleStore } from '../stores';
|
||||||
import { Footer } from '../components/footer';
|
import { Footer } from '../components/footer';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import DialogsContainer from './dialogs-container';
|
import DialogsContainer from './dialogs-container';
|
||||||
import ToastsManager from './toasts-manager';
|
import ToastsManager from './toasts-manager';
|
||||||
import { HashRouter, useLocation, useSearchParams } from 'react-router-dom';
|
import { HashRouter, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
@ -35,6 +33,7 @@ import { Connectors } from '../lib/vega-connectors';
|
|||||||
import { ViewingBanner } from '../components/viewing-banner';
|
import { ViewingBanner } from '../components/viewing-banner';
|
||||||
import { Banner } from '../components/banner';
|
import { Banner } from '../components/banner';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { AppLoader } from '../components/app-loader';
|
||||||
|
|
||||||
const DEFAULT_TITLE = t('Welcome to Vega trading!');
|
const DEFAULT_TITLE = t('Welcome to Vega trading!');
|
||||||
|
|
||||||
@ -84,57 +83,47 @@ function AppBody({ Component }: AppProps) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
</Head>
|
</Head>
|
||||||
<Title />
|
<Title />
|
||||||
<VegaWalletProvider>
|
<div className={gridClasses}>
|
||||||
<AppLoader>
|
<Navbar
|
||||||
<Web3Provider>
|
navbarTheme={VEGA_ENV === Networks.TESTNET ? 'yellow' : 'dark'}
|
||||||
<div className={gridClasses}>
|
/>
|
||||||
<Navbar
|
<Banner />
|
||||||
navbarTheme={VEGA_ENV === Networks.TESTNET ? 'yellow' : 'dark'}
|
<ViewingBanner />
|
||||||
/>
|
<main data-testid={location.pathname}>
|
||||||
<Banner />
|
<Component />
|
||||||
<ViewingBanner />
|
</main>
|
||||||
<main data-testid={location.pathname}>
|
<Footer />
|
||||||
<Component />
|
</div>
|
||||||
</main>
|
<DialogsContainer />
|
||||||
<Footer />
|
<ToastsManager />
|
||||||
</div>
|
<TransactionsHandler />
|
||||||
<DialogsContainer />
|
<MaybeConnectEagerly />
|
||||||
<ToastsManager />
|
|
||||||
<TransactionsHandler />
|
|
||||||
<MaybeConnectEagerly />
|
|
||||||
</Web3Provider>
|
|
||||||
</AppLoader>
|
|
||||||
</VegaWalletProvider>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DynamicLoader = dynamic(
|
|
||||||
() => import('../components/preloader/preloader'),
|
|
||||||
{
|
|
||||||
loading: () => <>Loading...</>,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function VegaTradingApp(props: AppProps) {
|
function VegaTradingApp(props: AppProps) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const status = useEnvironment((store) => store.status);
|
||||||
|
const { nodeSwitcherOpen, setNodeSwitcher } = useGlobalStore((store) => ({
|
||||||
|
nodeSwitcherOpen: store.nodeSwitcherDialog,
|
||||||
|
setNodeSwitcher: (open: boolean) =>
|
||||||
|
store.update({ nodeSwitcherDialog: open }),
|
||||||
|
}));
|
||||||
|
|
||||||
// Hash router requires access to the document object. At compile time that doesn't exist
|
useInitializeEnv();
|
||||||
// so we need to ensure client side rendering only from this point onwards in
|
|
||||||
// the component tree
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) {
|
// Prevent HashRouter from being server side rendered as it
|
||||||
return <DynamicLoader />;
|
// relies on presence of document object
|
||||||
|
if (status === 'default') {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<EnvironmentProvider>
|
<AppLoader>
|
||||||
<AppBody {...props} />
|
<AppBody {...props} />
|
||||||
</EnvironmentProvider>
|
</AppLoader>
|
||||||
|
<NodeSwitcherDialog open={nodeSwitcherOpen} setOpen={setNodeSwitcher} />
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { create } from 'zustand';
|
|||||||
import produce from 'immer';
|
import produce from 'immer';
|
||||||
|
|
||||||
interface GlobalStore {
|
interface GlobalStore {
|
||||||
networkSwitcherDialog: boolean;
|
nodeSwitcherDialog: boolean;
|
||||||
marketId: string | null;
|
marketId: string | null;
|
||||||
update: (store: Partial<Omit<GlobalStore, 'update'>>) => void;
|
update: (store: Partial<Omit<GlobalStore, 'update'>>) => void;
|
||||||
shouldDisplayWelcomeDialog: boolean;
|
shouldDisplayWelcomeDialog: boolean;
|
||||||
@ -16,7 +16,7 @@ interface PageTitleStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useGlobalStore = create<GlobalStore>((set) => ({
|
export const useGlobalStore = create<GlobalStore>((set) => ({
|
||||||
networkSwitcherDialog: false,
|
nodeSwitcherDialog: false,
|
||||||
marketId: LocalStorage.getItem('marketId') || null,
|
marketId: LocalStorage.getItem('marketId') || null,
|
||||||
shouldDisplayWelcomeDialog: false,
|
shouldDisplayWelcomeDialog: false,
|
||||||
shouldDisplayAnnouncementBanner: true,
|
shouldDisplayAnnouncementBanner: true,
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export * from './lib/apollo-client';
|
export * from './lib/apollo-client';
|
||||||
export * from './cache-config';
|
export * from './cache-config';
|
||||||
|
export * from './lib/header-store';
|
||||||
|
@ -14,6 +14,7 @@ import { onError } from '@apollo/client/link/error';
|
|||||||
import { RetryLink } from '@apollo/client/link/retry';
|
import { RetryLink } from '@apollo/client/link/retry';
|
||||||
import ApolloLinkTimeout from 'apollo-link-timeout';
|
import ApolloLinkTimeout from 'apollo-link-timeout';
|
||||||
import { localLoggerFactory } from '@vegaprotocol/react-helpers';
|
import { localLoggerFactory } from '@vegaprotocol/react-helpers';
|
||||||
|
import { useHeaderStore } from './header-store';
|
||||||
|
|
||||||
const isBrowser = typeof window !== 'undefined';
|
const isBrowser = typeof window !== 'undefined';
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ export type ClientOptions = {
|
|||||||
cacheConfig?: InMemoryCacheConfig;
|
cacheConfig?: InMemoryCacheConfig;
|
||||||
retry?: boolean;
|
retry?: boolean;
|
||||||
connectToDevTools?: boolean;
|
connectToDevTools?: boolean;
|
||||||
|
connectToHeaderStore?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createClient({
|
export function createClient({
|
||||||
@ -31,6 +33,7 @@ export function createClient({
|
|||||||
cacheConfig,
|
cacheConfig,
|
||||||
retry = true,
|
retry = true,
|
||||||
connectToDevTools = true,
|
connectToDevTools = true,
|
||||||
|
connectToHeaderStore = true,
|
||||||
}: ClientOptions) {
|
}: ClientOptions) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error('url must be passed into createClient!');
|
throw new Error('url must be passed into createClient!');
|
||||||
@ -47,6 +50,28 @@ export function createClient({
|
|||||||
const timeoutLink = new ApolloLinkTimeout(10000);
|
const timeoutLink = new ApolloLinkTimeout(10000);
|
||||||
const enlargedTimeoutLink = new ApolloLinkTimeout(100000);
|
const enlargedTimeoutLink = new ApolloLinkTimeout(100000);
|
||||||
|
|
||||||
|
const headerLink = connectToHeaderStore
|
||||||
|
? new ApolloLink((operation, forward) => {
|
||||||
|
return forward(operation).map((response) => {
|
||||||
|
const context = operation.getContext();
|
||||||
|
const r = context['response'];
|
||||||
|
const blockHeight = r?.headers.get('x-block-height');
|
||||||
|
const timestamp = r?.headers.get('x-block-timestamp');
|
||||||
|
if (blockHeight && timestamp) {
|
||||||
|
const state = useHeaderStore.getState();
|
||||||
|
useHeaderStore.setState({
|
||||||
|
...state,
|
||||||
|
[r.url]: {
|
||||||
|
blockHeight: Number(blockHeight),
|
||||||
|
timestamp: new Date(Number(timestamp.slice(0, -6))),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
: noOpLink;
|
||||||
|
|
||||||
const retryLink = retry
|
const retryLink = retry
|
||||||
? new RetryLink({
|
? new RetryLink({
|
||||||
delay: {
|
delay: {
|
||||||
@ -104,7 +129,13 @@ export function createClient({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return new ApolloClient({
|
return new ApolloClient({
|
||||||
link: from([errorLink, composedTimeoutLink, retryLink, splitLink]),
|
link: from([
|
||||||
|
errorLink,
|
||||||
|
composedTimeoutLink,
|
||||||
|
retryLink,
|
||||||
|
headerLink,
|
||||||
|
splitLink,
|
||||||
|
]),
|
||||||
cache: new InMemoryCache(cacheConfig),
|
cache: new InMemoryCache(cacheConfig),
|
||||||
connectToDevTools,
|
connectToDevTools,
|
||||||
});
|
});
|
||||||
|
12
libs/apollo-client/src/lib/header-store.ts
Normal file
12
libs/apollo-client/src/lib/header-store.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface HeaderEntry {
|
||||||
|
blockHeight: number;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderStore = {
|
||||||
|
[url: string]: HeaderEntry | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHeaderStore = create<HeaderStore>(() => ({}));
|
18
libs/environment/__mocks__/zustand.js
Normal file
18
libs/environment/__mocks__/zustand.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
const zu = jest.requireActual('zustand'); // if using jest
|
||||||
|
|
||||||
|
// a variable to hold reset functions for all stores declared in the app
|
||||||
|
const storeResetFns = new Set();
|
||||||
|
|
||||||
|
// when creating a store, we get its initial state, create a reset function and add it in the set
|
||||||
|
export const create = (createState) => {
|
||||||
|
const store = zu.create(createState);
|
||||||
|
const initialState = store.getState();
|
||||||
|
storeResetFns.add(() => store.setState(initialState, true));
|
||||||
|
return store;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset all stores after each test run
|
||||||
|
beforeEach(() => {
|
||||||
|
act(() => storeResetFns.forEach((resetFn) => resetFn()));
|
||||||
|
});
|
@ -1,4 +1,4 @@
|
|||||||
export * from './network-loader';
|
export * from './network-loader';
|
||||||
export * from './network-switcher';
|
export * from './network-switcher';
|
||||||
|
export * from './node-guard';
|
||||||
export * from './node-switcher';
|
export * from './node-switcher';
|
||||||
export * from './node-switcher-dialog';
|
|
||||||
|
@ -23,6 +23,7 @@ describe('Network loader', () => {
|
|||||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||||
useEnvironment.mockImplementation(() => ({
|
useEnvironment.mockImplementation(() => ({
|
||||||
VEGA_URL: undefined,
|
VEGA_URL: undefined,
|
||||||
|
status: 'success',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@ -30,7 +31,7 @@ describe('Network loader', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText(SKELETON_TEXT)).toBeInTheDocument();
|
expect(screen.getByText(SKELETON_TEXT)).toBeInTheDocument();
|
||||||
expect(() => screen.getByText(SUCCESS_TEXT)).toThrow();
|
expect(screen.queryByText(SUCCESS_TEXT)).not.toBeInTheDocument();
|
||||||
expect(createClient).not.toHaveBeenCalled();
|
expect(createClient).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ describe('Network loader', () => {
|
|||||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||||
useEnvironment.mockImplementation(() => ({
|
useEnvironment.mockImplementation(() => ({
|
||||||
VEGA_URL: 'http://vega.node',
|
VEGA_URL: 'http://vega.node',
|
||||||
|
status: 'success',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@ -50,6 +52,6 @@ describe('Network loader', () => {
|
|||||||
url: 'http://vega.node',
|
url: 'http://vega.node',
|
||||||
cacheConfig: undefined,
|
cacheConfig: undefined,
|
||||||
});
|
});
|
||||||
expect(await screen.findByText(SUCCESS_TEXT)).toBeInTheDocument();
|
expect(screen.getByText(SUCCESS_TEXT)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -8,32 +8,40 @@ import { createClient } from '@vegaprotocol/apollo-client';
|
|||||||
type NetworkLoaderProps = {
|
type NetworkLoaderProps = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
skeleton?: ReactNode;
|
skeleton?: ReactNode;
|
||||||
|
failure?: ReactNode;
|
||||||
cache?: InMemoryCacheConfig;
|
cache?: InMemoryCacheConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NetworkLoader({
|
export function NetworkLoader({
|
||||||
skeleton,
|
skeleton,
|
||||||
|
failure,
|
||||||
children,
|
children,
|
||||||
cache,
|
cache,
|
||||||
}: NetworkLoaderProps) {
|
}: NetworkLoaderProps) {
|
||||||
const { VEGA_URL } = useEnvironment();
|
const { status, VEGA_URL } = useEnvironment((store) => ({
|
||||||
|
status: store.status,
|
||||||
|
VEGA_URL: store.VEGA_URL,
|
||||||
|
}));
|
||||||
|
|
||||||
const client = useMemo(() => {
|
const client = useMemo(() => {
|
||||||
if (VEGA_URL) {
|
if (status === 'success' && VEGA_URL) {
|
||||||
return createClient({
|
return createClient({
|
||||||
url: VEGA_URL,
|
url: VEGA_URL,
|
||||||
cacheConfig: cache,
|
cacheConfig: cache,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [VEGA_URL, cache]);
|
}, [VEGA_URL, status, cache]);
|
||||||
|
|
||||||
if (!client) {
|
const nonIdealWrapperClasses =
|
||||||
return (
|
'h-full min-h-screen flex items-center justify-center';
|
||||||
<div className="h-full min-h-screen flex items-center justify-center">
|
|
||||||
{skeleton}
|
if (status === 'failed') {
|
||||||
</div>
|
return <div className={nonIdealWrapperClasses}>{failure}</div>;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
if (status === 'default' || status === 'pending' || !client) {
|
||||||
|
return <div className={nonIdealWrapperClasses}>{skeleton}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ApolloProvider client={client}>{children}</ApolloProvider>;
|
return <ApolloProvider client={client}>{children}</ApolloProvider>;
|
||||||
|
1
libs/environment/src/components/node-guard/index.ts
Normal file
1
libs/environment/src/components/node-guard/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './node-guard';
|
26
libs/environment/src/components/node-guard/node-guard.tsx
Normal file
26
libs/environment/src/components/node-guard/node-guard.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useStatisticsQuery } from '../../utils/__generated__/Node';
|
||||||
|
|
||||||
|
export const NodeGuard = ({
|
||||||
|
children,
|
||||||
|
failure,
|
||||||
|
skeleton,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
failure: ReactNode;
|
||||||
|
skeleton: ReactNode;
|
||||||
|
}) => {
|
||||||
|
const { error, loading } = useStatisticsQuery();
|
||||||
|
const wrapperClasses = 'h-full min-h-screen flex items-center justify-center';
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className={wrapperClasses}>{skeleton}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className={wrapperClasses}>{failure}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
@ -1 +0,0 @@
|
|||||||
export * from './node-switcher-dialog';
|
|
@ -1,59 +0,0 @@
|
|||||||
import type { ComponentProps } from 'react';
|
|
||||||
import { Dialog, Loader } from '@vegaprotocol/ui-toolkit';
|
|
||||||
import { t } from '@vegaprotocol/react-helpers';
|
|
||||||
import { NodeSwitcher } from '../node-switcher';
|
|
||||||
import { useEnvironment } from '../../hooks/use-environment';
|
|
||||||
import type { Configuration } from '../../types';
|
|
||||||
|
|
||||||
type NodeSwitcherDialogProps = Pick<
|
|
||||||
ComponentProps<typeof NodeSwitcher>,
|
|
||||||
'initialErrorType' | 'onConnect'
|
|
||||||
> & {
|
|
||||||
loading: boolean;
|
|
||||||
config?: Configuration;
|
|
||||||
dialogOpen: boolean;
|
|
||||||
setDialogOpen: (dialogOpen: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NodeSwitcherDialog = ({
|
|
||||||
config,
|
|
||||||
loading,
|
|
||||||
initialErrorType,
|
|
||||||
dialogOpen,
|
|
||||||
setDialogOpen,
|
|
||||||
onConnect,
|
|
||||||
}: NodeSwitcherDialogProps) => {
|
|
||||||
const { VEGA_ENV } = useEnvironment();
|
|
||||||
return (
|
|
||||||
<Dialog open={dialogOpen} onChange={setDialogOpen} size="medium">
|
|
||||||
<div className="uppercase text-xl text-center mb-2">
|
|
||||||
{t('Connected node')}
|
|
||||||
</div>
|
|
||||||
{!config && loading && (
|
|
||||||
<div className="py-8">
|
|
||||||
<p className="mb-4 text-center">{t('Loading configuration...')}</p>
|
|
||||||
<Loader size="large" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{config && dialogOpen && (
|
|
||||||
<>
|
|
||||||
<p className="mb-2 text-center">
|
|
||||||
{t(`This app will only work on a `)}
|
|
||||||
<span className="font-mono capitalize">
|
|
||||||
{VEGA_ENV.toLowerCase()}
|
|
||||||
</span>
|
|
||||||
{t(' chain ID')}
|
|
||||||
</p>
|
|
||||||
<NodeSwitcher
|
|
||||||
config={config}
|
|
||||||
initialErrorType={initialErrorType}
|
|
||||||
onConnect={(url) => {
|
|
||||||
onConnect(url);
|
|
||||||
setDialogOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
@ -0,0 +1,25 @@
|
|||||||
|
import { ApolloProvider } from '@apollo/client';
|
||||||
|
import { createClient } from '@vegaprotocol/apollo-client';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export const ApolloWrapper = ({
|
||||||
|
url,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) => {
|
||||||
|
const client = useMemo(
|
||||||
|
() =>
|
||||||
|
createClient({
|
||||||
|
url,
|
||||||
|
cacheConfig: undefined,
|
||||||
|
retry: false,
|
||||||
|
connectToDevTools: false,
|
||||||
|
connectToHeaderStore: true,
|
||||||
|
}),
|
||||||
|
[url]
|
||||||
|
);
|
||||||
|
return <ApolloProvider client={client}>{children}</ApolloProvider>;
|
||||||
|
};
|
@ -1 +1,2 @@
|
|||||||
|
export * from './node-switcher-dialog';
|
||||||
export * from './node-switcher';
|
export * from './node-switcher';
|
||||||
|
@ -9,7 +9,7 @@ export const LayoutRow = ({ children, dataTestId }: LayoutRowProps) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={dataTestId}
|
data-testid={dataTestId}
|
||||||
className="lg:grid lg:gap-2 py-2 w-full lg:grid-cols-[minmax(200px,_1fr),_150px_125px_100px]"
|
className="lg:grid lg:gap-2 py-2 w-full lg:grid-cols-[minmax(200px,_1fr),_125px_125px_125px]"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useStatisticsQuery } from '../../utils/__generated__/Node';
|
|
||||||
|
|
||||||
type NodeBlockHeightProps = {
|
|
||||||
value?: number;
|
|
||||||
setValue: (value: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const POLL_INTERVAL = 3000;
|
|
||||||
|
|
||||||
export const NodeBlockHeight = ({ value, setValue }: NodeBlockHeightProps) => {
|
|
||||||
const { data, startPolling, stopPolling } = useStatisticsQuery({
|
|
||||||
pollInterval: POLL_INTERVAL,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleStartPoll = () => startPolling(POLL_INTERVAL);
|
|
||||||
const handleStopPoll = () => stopPolling();
|
|
||||||
window.addEventListener('blur', handleStopPoll);
|
|
||||||
window.addEventListener('focus', handleStartPoll);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('blur', handleStopPoll);
|
|
||||||
window.removeEventListener('focus', handleStartPoll);
|
|
||||||
};
|
|
||||||
}, [startPolling, stopPolling]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data?.statistics?.blockHeight) {
|
|
||||||
setValue(Number(data.statistics.blockHeight));
|
|
||||||
}
|
|
||||||
}, [setValue, data?.statistics?.blockHeight]);
|
|
||||||
|
|
||||||
return <span>{value ?? '-'}</span>;
|
|
||||||
};
|
|
@ -1,137 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { ApolloProvider } from '@apollo/client';
|
|
||||||
import { t } from '@vegaprotocol/react-helpers';
|
|
||||||
import type { NodeData } from '../../types';
|
|
||||||
import { LayoutRow } from './layout-row';
|
|
||||||
import { LayoutCell } from './layout-cell';
|
|
||||||
import { NodeBlockHeight } from './node-block-height';
|
|
||||||
import type { createClient } from '@vegaprotocol/apollo-client';
|
|
||||||
|
|
||||||
type NodeStatsContentProps = {
|
|
||||||
data?: NodeData;
|
|
||||||
highestBlock: number;
|
|
||||||
setBlock: (value: number) => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
dataTestId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getResponseTimeDisplayValue = (
|
|
||||||
responseTime?: NodeData['responseTime']
|
|
||||||
) => {
|
|
||||||
if (typeof responseTime?.value === 'number') {
|
|
||||||
return `${Number(responseTime.value).toFixed(2)}ms`;
|
|
||||||
}
|
|
||||||
if (responseTime?.hasError) {
|
|
||||||
return t('n/a');
|
|
||||||
}
|
|
||||||
return '-';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBlockDisplayValue = (
|
|
||||||
block: NodeData['block'] | undefined,
|
|
||||||
setBlock: (block: number) => void
|
|
||||||
) => {
|
|
||||||
if (block?.value) {
|
|
||||||
return <NodeBlockHeight value={block?.value} setValue={setBlock} />;
|
|
||||||
}
|
|
||||||
if (block?.hasError) {
|
|
||||||
return t('n/a');
|
|
||||||
}
|
|
||||||
return '-';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSubscriptionDisplayValue = (
|
|
||||||
subscription?: NodeData['subscription']
|
|
||||||
) => {
|
|
||||||
if (subscription?.value) {
|
|
||||||
return t('Yes');
|
|
||||||
}
|
|
||||||
if (subscription?.hasError) {
|
|
||||||
return t('No');
|
|
||||||
}
|
|
||||||
return '-';
|
|
||||||
};
|
|
||||||
|
|
||||||
const NodeStatsContent = ({
|
|
||||||
// @ts-ignore Allow defaulting to an empty object
|
|
||||||
data = {},
|
|
||||||
highestBlock,
|
|
||||||
setBlock,
|
|
||||||
children,
|
|
||||||
dataTestId,
|
|
||||||
}: NodeStatsContentProps) => {
|
|
||||||
return (
|
|
||||||
<LayoutRow dataTestId={dataTestId}>
|
|
||||||
{children}
|
|
||||||
<LayoutCell
|
|
||||||
label={t('Response time')}
|
|
||||||
isLoading={data.responseTime?.isLoading}
|
|
||||||
hasError={data.responseTime?.hasError}
|
|
||||||
dataTestId="response-time-cell"
|
|
||||||
>
|
|
||||||
{getResponseTimeDisplayValue(data.responseTime)}
|
|
||||||
</LayoutCell>
|
|
||||||
<LayoutCell
|
|
||||||
label={t('Block')}
|
|
||||||
isLoading={data.block?.isLoading}
|
|
||||||
hasError={
|
|
||||||
data.block?.hasError ||
|
|
||||||
(!!data.block?.value && highestBlock > data.block.value)
|
|
||||||
}
|
|
||||||
dataTestId="block-cell"
|
|
||||||
>
|
|
||||||
{getBlockDisplayValue(data.block, setBlock)}
|
|
||||||
</LayoutCell>
|
|
||||||
<LayoutCell
|
|
||||||
label={t('Subscription')}
|
|
||||||
isLoading={data.subscription?.isLoading}
|
|
||||||
hasError={data.subscription?.hasError}
|
|
||||||
dataTestId="subscription-cell"
|
|
||||||
>
|
|
||||||
{getSubscriptionDisplayValue(data.subscription)}
|
|
||||||
</LayoutCell>
|
|
||||||
</LayoutRow>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type WrapperProps = {
|
|
||||||
client?: ReturnType<typeof createClient>;
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Wrapper = ({ client, children }: WrapperProps) => {
|
|
||||||
if (client) {
|
|
||||||
return <ApolloProvider client={client}>{children}</ApolloProvider>;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NodeStatsProps = {
|
|
||||||
data?: NodeData;
|
|
||||||
client?: ReturnType<typeof createClient>;
|
|
||||||
highestBlock: number;
|
|
||||||
setBlock: (value: number) => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NodeStats = ({
|
|
||||||
data,
|
|
||||||
client,
|
|
||||||
highestBlock,
|
|
||||||
children,
|
|
||||||
setBlock,
|
|
||||||
}: NodeStatsProps) => {
|
|
||||||
return (
|
|
||||||
<Wrapper client={client}>
|
|
||||||
<NodeStatsContent
|
|
||||||
data={data}
|
|
||||||
highestBlock={highestBlock}
|
|
||||||
setBlock={setBlock}
|
|
||||||
dataTestId="node-row"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</NodeStatsContent>
|
|
||||||
</Wrapper>
|
|
||||||
);
|
|
||||||
};
|
|
@ -0,0 +1,16 @@
|
|||||||
|
import { Dialog } from '@vegaprotocol/ui-toolkit';
|
||||||
|
import { NodeSwitcher } from './node-switcher';
|
||||||
|
|
||||||
|
export const NodeSwitcherDialog = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (x: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onChange={setOpen} size="medium">
|
||||||
|
<NodeSwitcher closeDialog={() => setOpen(false)} />
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
@ -1,584 +0,0 @@
|
|||||||
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 { StatisticsDocument } from '../../utils/__generated__/Node';
|
|
||||||
import { NodeSwitcher } from './node-switcher';
|
|
||||||
import { getErrorByType } from '../../utils/validate-node';
|
|
||||||
import type { Configuration, NodeData } from '../../types';
|
|
||||||
import { Networks, ErrorType, CUSTOM_NODE_KEY } from '../../types';
|
|
||||||
|
|
||||||
type NodeDataProp = 'responseTime' | 'block' | 'chain' | 'subscription';
|
|
||||||
|
|
||||||
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: StatisticsDocument,
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
subscription: {
|
|
||||||
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}
|
|
||||||
${'subscription'} | ${STATES.LOADING}
|
|
||||||
${'subscription'} | ${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('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}
|
|
||||||
${'subscription'} | ${STATES.LOADING}
|
|
||||||
${'subscription'} | ${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 === 'subscription'
|
|
||||||
? ErrorType.SUBSCRIPTION_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.each`
|
|
||||||
description | errorType
|
|
||||||
${'the node has an invalid url'} | ${ErrorType.INVALID_URL}
|
|
||||||
${'the node has a subscription issue'} | ${ErrorType.SUBSCRIPTION_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,197 +1,237 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { t } from '@vegaprotocol/react-helpers';
|
import { isValidUrl, t } from '@vegaprotocol/react-helpers';
|
||||||
import {
|
import {
|
||||||
RadioGroup,
|
|
||||||
Button,
|
Button,
|
||||||
|
ButtonLink,
|
||||||
Input,
|
Input,
|
||||||
Link,
|
Loader,
|
||||||
Radio,
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
} from '@vegaprotocol/ui-toolkit';
|
} from '@vegaprotocol/ui-toolkit';
|
||||||
import { useEnvironment } from '../../hooks/use-environment';
|
import { useEnvironment } from '../../hooks';
|
||||||
import { useNodes } from '../../hooks/use-nodes';
|
|
||||||
import {
|
|
||||||
getIsNodeLoading,
|
|
||||||
getIsNodeDisabled,
|
|
||||||
getIsFormDisabled,
|
|
||||||
getErrorType,
|
|
||||||
getErrorByType,
|
|
||||||
} from '../../utils/validate-node';
|
|
||||||
import { CUSTOM_NODE_KEY } from '../../types';
|
import { CUSTOM_NODE_KEY } from '../../types';
|
||||||
import type { Configuration, NodeData, ErrorType } from '../../types';
|
import { LayoutCell } from './layout-cell';
|
||||||
import { LayoutRow } from './layout-row';
|
import { LayoutRow } from './layout-row';
|
||||||
import { NodeError } from './node-error';
|
import { ApolloWrapper } from './apollo-wrapper';
|
||||||
import { NodeStats } from './node-stats';
|
import { RowData } from './row-data';
|
||||||
|
|
||||||
type NodeSwitcherProps = {
|
export const NodeSwitcher = ({ closeDialog }: { closeDialog: () => void }) => {
|
||||||
error?: string;
|
const { nodes, setUrl, status, VEGA_ENV, VEGA_URL } = useEnvironment(
|
||||||
config: Configuration;
|
(store) => ({
|
||||||
initialErrorType?: ErrorType;
|
status: store.status,
|
||||||
onConnect: (url: string) => void;
|
nodes: store.nodes,
|
||||||
};
|
setUrl: store.setUrl,
|
||||||
|
VEGA_ENV: store.VEGA_ENV,
|
||||||
const getDefaultNode = (urls: string[], currentUrl?: string) => {
|
VEGA_URL: store.VEGA_URL,
|
||||||
return currentUrl && urls.includes(currentUrl) ? currentUrl : undefined;
|
})
|
||||||
};
|
|
||||||
|
|
||||||
const getHighestBlock = (state: Record<string, NodeData>) => {
|
|
||||||
return Object.keys(state).reduce((acc, node) => {
|
|
||||||
const value = Number(state[node].block.value);
|
|
||||||
return value ? Math.max(acc, value) : acc;
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NodeSwitcher = ({
|
|
||||||
config,
|
|
||||||
initialErrorType,
|
|
||||||
onConnect,
|
|
||||||
}: NodeSwitcherProps) => {
|
|
||||||
const { VEGA_ENV, VEGA_URL } = useEnvironment();
|
|
||||||
const [networkError, setNetworkError] = useState(
|
|
||||||
getErrorByType(initialErrorType, VEGA_ENV, VEGA_URL)
|
|
||||||
);
|
);
|
||||||
const [customNodeText, setCustomNodeText] = useState('');
|
|
||||||
const [nodeRadio, setNodeRadio] = useState(
|
|
||||||
getDefaultNode(config.hosts, VEGA_URL)
|
|
||||||
);
|
|
||||||
const { state, clients, updateNodeUrl, updateNodeBlock } = useNodes(config);
|
|
||||||
const highestBlock = getHighestBlock(state);
|
|
||||||
|
|
||||||
const customUrl = state[CUSTOM_NODE_KEY]?.url;
|
const [nodeRadio, setNodeRadio] = useState<string>(() => {
|
||||||
|
if (VEGA_URL) {
|
||||||
const onSubmit = (node: ReturnType<typeof getDefaultNode>) => {
|
return VEGA_URL;
|
||||||
if (node && state[node]) {
|
|
||||||
onConnect(state[node].url);
|
|
||||||
}
|
}
|
||||||
};
|
return nodes.length > 0 ? '' : CUSTOM_NODE_KEY;
|
||||||
|
});
|
||||||
|
const [highestBlock, setHighestBlock] = useState<number | null>(null);
|
||||||
|
const [customUrlText, setCustomUrlText] = useState('');
|
||||||
|
|
||||||
const isSubmitDisabled = getIsFormDisabled(nodeRadio, VEGA_ENV, state);
|
const handleHighestBlock = useCallback((blockHeight: number) => {
|
||||||
|
setHighestBlock((curr) => {
|
||||||
|
if (curr === null) {
|
||||||
|
return blockHeight;
|
||||||
|
}
|
||||||
|
if (blockHeight > curr) {
|
||||||
|
return blockHeight;
|
||||||
|
}
|
||||||
|
return curr;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const customNodeData =
|
let isDisabled = false;
|
||||||
nodeRadio &&
|
if (nodeRadio === '') {
|
||||||
state[CUSTOM_NODE_KEY] &&
|
isDisabled = true;
|
||||||
state[CUSTOM_NODE_KEY].url === customNodeText
|
} else if (nodeRadio === VEGA_URL) {
|
||||||
? state[nodeRadio]
|
isDisabled = true;
|
||||||
: undefined;
|
} else if (nodeRadio === CUSTOM_NODE_KEY) {
|
||||||
|
if (!isValidUrl(customUrlText)) {
|
||||||
const customNodeError = getErrorByType(
|
isDisabled = true;
|
||||||
getErrorType(VEGA_ENV, customNodeData),
|
}
|
||||||
VEGA_ENV,
|
}
|
||||||
customUrl
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-black dark:text-white w-full lg:min-w-[800px]">
|
<div>
|
||||||
<NodeError {...(customNodeError || networkError)} />
|
<h3 className="uppercase text-xl calt text-center mb-2">
|
||||||
<form
|
{t('Connected node')}
|
||||||
onSubmit={(event) => {
|
</h3>
|
||||||
event.preventDefault();
|
{status === 'pending' ? (
|
||||||
onSubmit(nodeRadio);
|
<div className="py-8">
|
||||||
}}
|
<p className="mb-4 text-center">{t('Loading configuration...')}</p>
|
||||||
>
|
<Loader size="large" />
|
||||||
<p className="text-lg mt-4">
|
</div>
|
||||||
{t('Select a GraphQL node to connect to:')}
|
) : (
|
||||||
</p>
|
<div>
|
||||||
<div className="mb-2">
|
<p className="mb-2 text-sm text-center">
|
||||||
<div className="hidden lg:block">
|
{t(
|
||||||
<LayoutRow>
|
`This app will only work on ${VEGA_ENV}. Select a node to connect to.`
|
||||||
<div />
|
)}
|
||||||
<span className="text-right">{t('Response time')}</span>
|
</p>
|
||||||
<span className="text-right">{t('Block')}</span>
|
|
||||||
<span className="text-right">{t('Subscription')}</span>
|
|
||||||
</LayoutRow>
|
|
||||||
</div>
|
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={nodeRadio}
|
value={nodeRadio}
|
||||||
onChange={(value) => {
|
onChange={(value) => setNodeRadio(value)}
|
||||||
setNodeRadio(value);
|
|
||||||
setNetworkError(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="w-full">
|
<div className="hidden lg:block">
|
||||||
{config.hosts.map((node, index) => (
|
<LayoutRow>
|
||||||
<NodeStats
|
<span>{t('Node')}</span>
|
||||||
key={index}
|
<span className="text-right">{t('Response time')}</span>
|
||||||
data={state[node]}
|
<span className="text-right">{t('Block')}</span>
|
||||||
client={clients[node]}
|
<span className="text-right">{t('Subscription')}</span>
|
||||||
|
</LayoutRow>
|
||||||
|
<div>
|
||||||
|
{nodes.map((node, index) => {
|
||||||
|
return (
|
||||||
|
<LayoutRow key={node} dataTestId="node-row">
|
||||||
|
<ApolloWrapper url={node}>
|
||||||
|
<RowData
|
||||||
|
id={index.toString()}
|
||||||
|
url={node}
|
||||||
|
highestBlock={highestBlock}
|
||||||
|
onBlockHeight={handleHighestBlock}
|
||||||
|
/>
|
||||||
|
</ApolloWrapper>
|
||||||
|
</LayoutRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<CustomRowWrapper
|
||||||
|
inputText={customUrlText}
|
||||||
|
setInputText={setCustomUrlText}
|
||||||
|
nodes={nodes}
|
||||||
highestBlock={highestBlock}
|
highestBlock={highestBlock}
|
||||||
setBlock={(block) => updateNodeBlock(node, block)}
|
onBlockHeight={handleHighestBlock}
|
||||||
>
|
nodeRadio={nodeRadio}
|
||||||
<div className="break-all" data-testid="node">
|
/>
|
||||||
<Radio
|
</div>
|
||||||
id={`node-url-${index}`}
|
|
||||||
value={node}
|
|
||||||
label={node}
|
|
||||||
disabled={getIsNodeDisabled(VEGA_ENV, state[node])}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</NodeStats>
|
|
||||||
))}
|
|
||||||
<NodeStats
|
|
||||||
data={state[CUSTOM_NODE_KEY]}
|
|
||||||
client={customUrl ? clients[customUrl] : undefined}
|
|
||||||
highestBlock={highestBlock}
|
|
||||||
setBlock={(block) => updateNodeBlock(CUSTOM_NODE_KEY, block)}
|
|
||||||
>
|
|
||||||
<div className="flex w-full mb-2">
|
|
||||||
<Radio
|
|
||||||
id={`node-url-custom`}
|
|
||||||
value={CUSTOM_NODE_KEY}
|
|
||||||
label={
|
|
||||||
nodeRadio === CUSTOM_NODE_KEY || !!state[CUSTOM_NODE_KEY]
|
|
||||||
? ''
|
|
||||||
: t('Other')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{(customNodeText || nodeRadio === CUSTOM_NODE_KEY) && (
|
|
||||||
<div
|
|
||||||
data-testid="custom-node"
|
|
||||||
className="flex items-center w-full gap-2"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="https://"
|
|
||||||
value={customNodeText}
|
|
||||||
hasError={
|
|
||||||
!!customNodeText &&
|
|
||||||
!!(
|
|
||||||
customNodeError?.headline ||
|
|
||||||
customNodeError?.message
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onChange={(e) => setCustomNodeText(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Link
|
|
||||||
aria-disabled={
|
|
||||||
!customNodeText ||
|
|
||||||
getIsNodeLoading(state[CUSTOM_NODE_KEY])
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
setNetworkError(null);
|
|
||||||
updateNodeUrl(CUSTOM_NODE_KEY, customNodeText);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{state[CUSTOM_NODE_KEY] &&
|
|
||||||
getIsNodeLoading(state[CUSTOM_NODE_KEY])
|
|
||||||
? t('Checking')
|
|
||||||
: t('Check')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</NodeStats>
|
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
fill={true}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (nodeRadio === CUSTOM_NODE_KEY) {
|
||||||
|
setUrl(customUrlText);
|
||||||
|
} else {
|
||||||
|
setUrl(nodeRadio);
|
||||||
|
}
|
||||||
|
closeDialog();
|
||||||
|
}}
|
||||||
|
data-testid="connect"
|
||||||
|
>
|
||||||
|
{t('Connect to this node')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<Button
|
|
||||||
disabled={isSubmitDisabled}
|
|
||||||
fill={true}
|
|
||||||
type="submit"
|
|
||||||
data-testid="connect"
|
|
||||||
>
|
|
||||||
{t('Connect')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface CustomRowWrapperProps {
|
||||||
|
inputText: string;
|
||||||
|
setInputText: (text: string) => void;
|
||||||
|
nodes: string[];
|
||||||
|
highestBlock: number | null;
|
||||||
|
nodeRadio: string;
|
||||||
|
onBlockHeight: (blockHeight: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomRowWrapper = ({
|
||||||
|
inputText,
|
||||||
|
setInputText,
|
||||||
|
nodes,
|
||||||
|
highestBlock,
|
||||||
|
nodeRadio,
|
||||||
|
onBlockHeight,
|
||||||
|
}: CustomRowWrapperProps) => {
|
||||||
|
const [displayCustom, setDisplayCustom] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const showInput = nodeRadio === CUSTOM_NODE_KEY || nodes.length <= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutRow dataTestId="custom-row">
|
||||||
|
<div className="flex w-full mb-2">
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<Radio
|
||||||
|
id="node-url-custom"
|
||||||
|
value={CUSTOM_NODE_KEY}
|
||||||
|
label={nodeRadio === CUSTOM_NODE_KEY ? '' : t('Other')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showInput && (
|
||||||
|
<div
|
||||||
|
data-testid="custom-node"
|
||||||
|
className="flex items-center w-full gap-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="https://"
|
||||||
|
value={inputText}
|
||||||
|
hasError={Boolean(error)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDisplayCustom(false);
|
||||||
|
setInputText(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ButtonLink
|
||||||
|
onClick={() => {
|
||||||
|
if (!isValidUrl(inputText)) {
|
||||||
|
setError('Invalid url');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setDisplayCustom(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Check')}
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{displayCustom ? (
|
||||||
|
<ApolloWrapper url={inputText}>
|
||||||
|
<RowData
|
||||||
|
id={CUSTOM_NODE_KEY}
|
||||||
|
url={inputText}
|
||||||
|
onBlockHeight={onBlockHeight}
|
||||||
|
highestBlock={highestBlock}
|
||||||
|
/>
|
||||||
|
</ApolloWrapper>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LayoutCell
|
||||||
|
label={t('Response time')}
|
||||||
|
isLoading={false}
|
||||||
|
hasError={false}
|
||||||
|
dataTestId="response-time-cell"
|
||||||
|
>
|
||||||
|
{'-'}
|
||||||
|
</LayoutCell>
|
||||||
|
<LayoutCell
|
||||||
|
label={t('Block')}
|
||||||
|
isLoading={false}
|
||||||
|
hasError={false}
|
||||||
|
dataTestId="block-height-cell"
|
||||||
|
>
|
||||||
|
{'-'}
|
||||||
|
</LayoutCell>
|
||||||
|
<LayoutCell
|
||||||
|
label={t('Subscription')}
|
||||||
|
isLoading={false}
|
||||||
|
hasError={false}
|
||||||
|
dataTestId="subscription -cell"
|
||||||
|
>
|
||||||
|
{'-'}
|
||||||
|
</LayoutCell>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</LayoutRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -0,0 +1,174 @@
|
|||||||
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
|
import { NodeSwitcher } from './node-switcher';
|
||||||
|
import type { EnvStore } from '../../hooks';
|
||||||
|
import { useEnvironment } from '../../hooks';
|
||||||
|
import { Networks } from '../../types';
|
||||||
|
import { MockedProvider } from '@apollo/react-testing';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
jest.mock('../../hooks/use-environment');
|
||||||
|
jest.mock('./apollo-wrapper', () => ({
|
||||||
|
ApolloWrapper: jest.fn(({ children }: { children: ReactNode }) => (
|
||||||
|
<MockedProvider>{children}</MockedProvider>
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.performance.getEntriesByName = jest.fn().mockReturnValue([]);
|
||||||
|
|
||||||
|
const mockEnv = (env: Partial<EnvStore>) => {
|
||||||
|
(useEnvironment as unknown as jest.Mock).mockImplementation(() => env);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('NodeSwitcher', () => {
|
||||||
|
it('renders with no nodes', () => {
|
||||||
|
mockEnv({
|
||||||
|
VEGA_ENV: Networks.TESTNET,
|
||||||
|
nodes: [],
|
||||||
|
});
|
||||||
|
render(<NodeSwitcher closeDialog={jest.fn()} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText(new RegExp(Networks.TESTNET, 'i'))
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryAllByTestId('node')).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Connect to this node' })
|
||||||
|
).toHaveAttribute('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with nodes', async () => {
|
||||||
|
const nodes = [
|
||||||
|
'https://n00.api.vega.xyz',
|
||||||
|
'https://n01.api.vega.xyz',
|
||||||
|
'https://n02.api.vega.xyz',
|
||||||
|
];
|
||||||
|
mockEnv({
|
||||||
|
VEGA_ENV: Networks.TESTNET,
|
||||||
|
nodes,
|
||||||
|
});
|
||||||
|
render(<NodeSwitcher closeDialog={jest.fn()} />);
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('radio', { checked: false, name: node })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole('radio', { checked: false, name: 'Other' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Connect to this node' })
|
||||||
|
).toHaveAttribute('disabled');
|
||||||
|
|
||||||
|
const rows = screen.getAllByTestId('node-row');
|
||||||
|
expect(rows).toHaveLength(nodes.length);
|
||||||
|
rows.forEach((r) => {
|
||||||
|
const row = within(r);
|
||||||
|
expect(row.getByTestId('response-time-cell')).toHaveTextContent(
|
||||||
|
'Checking'
|
||||||
|
);
|
||||||
|
expect(row.getByTestId('block-height-cell')).toHaveTextContent(
|
||||||
|
'Checking'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note actual requests tested in
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks current node as selected', () => {
|
||||||
|
const nodes = [
|
||||||
|
'https://n00.api.vega.xyz',
|
||||||
|
'https://n01.api.vega.xyz',
|
||||||
|
'https://n02.api.vega.xyz',
|
||||||
|
];
|
||||||
|
const selectedNode = nodes[0];
|
||||||
|
mockEnv({
|
||||||
|
VEGA_ENV: Networks.TESTNET,
|
||||||
|
VEGA_URL: selectedNode,
|
||||||
|
nodes,
|
||||||
|
});
|
||||||
|
render(<NodeSwitcher closeDialog={jest.fn()} />);
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('radio', {
|
||||||
|
checked: node === selectedNode,
|
||||||
|
name: node,
|
||||||
|
})
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole('radio', { checked: false, name: 'Other' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Connect to this node' })
|
||||||
|
).toHaveAttribute('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows setting a custom node', () => {
|
||||||
|
const mockSetUrl = jest.fn();
|
||||||
|
const mockUrl = 'https://custom.url';
|
||||||
|
const nodes = ['https://n00.api.vega.xyz'];
|
||||||
|
mockEnv({
|
||||||
|
VEGA_ENV: Networks.TESTNET,
|
||||||
|
nodes,
|
||||||
|
setUrl: mockSetUrl,
|
||||||
|
});
|
||||||
|
render(<NodeSwitcher closeDialog={jest.fn()} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Connect to this node' })
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), {
|
||||||
|
target: {
|
||||||
|
value: mockUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue(mockUrl);
|
||||||
|
expect(screen.getByRole('button', { name: 'Check' })).not.toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Check' }));
|
||||||
|
|
||||||
|
const customRow = within(screen.getByTestId('custom-row'));
|
||||||
|
expect(customRow.getByTestId('block-height-cell')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: 'Connect to this node' })
|
||||||
|
);
|
||||||
|
expect(mockSetUrl).toHaveBeenCalledWith(mockUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables a custom node with an invalid url', () => {
|
||||||
|
const mockSetUrl = jest.fn();
|
||||||
|
const mockUrl = 'invalid-url';
|
||||||
|
const nodes = [
|
||||||
|
'https://n00.api.vega.xyz',
|
||||||
|
'https://n01.api.vega.xyz',
|
||||||
|
'https://n02.api.vega.xyz',
|
||||||
|
];
|
||||||
|
mockEnv({
|
||||||
|
VEGA_ENV: Networks.TESTNET,
|
||||||
|
nodes,
|
||||||
|
setUrl: mockSetUrl,
|
||||||
|
});
|
||||||
|
render(<NodeSwitcher closeDialog={jest.fn()} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Connect to this node' })
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), {
|
||||||
|
target: {
|
||||||
|
value: mockUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'Connect to this node',
|
||||||
|
})
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.todo('displays errors');
|
||||||
|
});
|
254
libs/environment/src/components/node-switcher/row-data.spec.tsx
Normal file
254
libs/environment/src/components/node-switcher/row-data.spec.tsx
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import type { MockedResponse } from '@apollo/react-testing';
|
||||||
|
import { MockedProvider } from '@apollo/react-testing';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { RadioGroup } from '@vegaprotocol/ui-toolkit';
|
||||||
|
import type {
|
||||||
|
BlockTimeSubscription,
|
||||||
|
StatisticsQuery,
|
||||||
|
} from '../../utils/__generated__/Node';
|
||||||
|
import { BlockTimeDocument } from '../../utils/__generated__/Node';
|
||||||
|
import { StatisticsDocument } from '../../utils/__generated__/Node';
|
||||||
|
import type { RowDataProps } from './row-data';
|
||||||
|
import { BLOCK_THRESHOLD, RowData } from './row-data';
|
||||||
|
import type { HeaderEntry } from '@vegaprotocol/apollo-client';
|
||||||
|
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||||
|
import { CUSTOM_NODE_KEY } from '../../types';
|
||||||
|
|
||||||
|
jest.mock('@vegaprotocol/apollo-client', () => ({
|
||||||
|
useHeaderStore: jest.fn().mockReturnValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const statsQueryMock: MockedResponse<StatisticsQuery> = {
|
||||||
|
request: {
|
||||||
|
query: StatisticsDocument,
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
data: {
|
||||||
|
statistics: {
|
||||||
|
blockHeight: '1234',
|
||||||
|
vegaTime: new Date().toISOString(),
|
||||||
|
chainId: 'test-chain-id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const subMock: MockedResponse<BlockTimeSubscription> = {
|
||||||
|
request: {
|
||||||
|
query: BlockTimeDocument,
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
data: {
|
||||||
|
busEvents: [
|
||||||
|
{
|
||||||
|
__typename: 'BusEvent',
|
||||||
|
id: '123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponseTime = 50;
|
||||||
|
global.performance.getEntriesByName = jest.fn().mockReturnValue([
|
||||||
|
{
|
||||||
|
duration: mockResponseTime,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mockHeaders = (
|
||||||
|
url: string,
|
||||||
|
headers: Partial<HeaderEntry> = {
|
||||||
|
blockHeight: 100,
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
(useHeaderStore as unknown as jest.Mock).mockReturnValue({
|
||||||
|
[url]: headers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderComponent = (
|
||||||
|
props: RowDataProps,
|
||||||
|
queryMock: MockedResponse<StatisticsQuery>,
|
||||||
|
subMock: MockedResponse<BlockTimeSubscription>
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<MockedProvider mocks={[queryMock, subMock, subMock, subMock]}>
|
||||||
|
<RadioGroup>
|
||||||
|
{/* Radio group required as radio is being render in isolation */}
|
||||||
|
<RowData {...props} />
|
||||||
|
</RadioGroup>
|
||||||
|
</MockedProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RowData', () => {
|
||||||
|
const props = {
|
||||||
|
id: '0',
|
||||||
|
url: 'https://foo.bar.com',
|
||||||
|
highestBlock: null,
|
||||||
|
onBlockHeight: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('radio button enabled after stats query successful', async () => {
|
||||||
|
mockHeaders(props.url);
|
||||||
|
render(renderComponent(props, statsQueryMock, subMock));
|
||||||
|
|
||||||
|
// radio should be disabled until query resolves
|
||||||
|
expect(
|
||||||
|
screen.getByRole('radio', {
|
||||||
|
checked: false,
|
||||||
|
name: props.url,
|
||||||
|
})
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(screen.getByTestId('response-time-cell')).toHaveTextContent(
|
||||||
|
'Checking'
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('block-height-cell')).toHaveTextContent(
|
||||||
|
'Checking'
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('block-height-cell')).toHaveTextContent('100');
|
||||||
|
expect(screen.getByTestId('response-time-cell')).toHaveTextContent(
|
||||||
|
mockResponseTime.toFixed(2) + 'ms'
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('subscription-cell')).toHaveTextContent('Yes');
|
||||||
|
expect(
|
||||||
|
screen.getByRole('radio', {
|
||||||
|
checked: false,
|
||||||
|
name: props.url,
|
||||||
|
})
|
||||||
|
).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('radio button disabled if query fails', async () => {
|
||||||
|
mockHeaders(props.url, {});
|
||||||
|
|
||||||
|
const failedQueryMock: MockedResponse<StatisticsQuery> = {
|
||||||
|
request: {
|
||||||
|
query: StatisticsDocument,
|
||||||
|
},
|
||||||
|
error: new Error('failed'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const failedSubMock: MockedResponse<BlockTimeSubscription> = {
|
||||||
|
request: {
|
||||||
|
query: BlockTimeDocument,
|
||||||
|
},
|
||||||
|
error: new Error('failed'),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(renderComponent(props, failedQueryMock, failedSubMock));
|
||||||
|
|
||||||
|
// radio should be disabled until query resolves
|
||||||
|
expect(
|
||||||
|
screen.getByRole('radio', {
|
||||||
|
checked: false,
|
||||||
|
name: props.url,
|
||||||
|
})
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(screen.getByTestId('response-time-cell')).toHaveTextContent(
|
||||||
|
'Checking'
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('block-height-cell')).toHaveTextContent(
|
||||||
|
'Checking'
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
const responseCell = screen.getByTestId('response-time-cell');
|
||||||
|
const blockHeightCell = screen.getByTestId('block-height-cell');
|
||||||
|
const subscriptionCell = screen.getByTestId('subscription-cell');
|
||||||
|
expect(responseCell).toHaveTextContent('n/a');
|
||||||
|
expect(responseCell).toHaveClass('text-danger');
|
||||||
|
expect(blockHeightCell).toHaveTextContent('n/a');
|
||||||
|
expect(blockHeightCell).toHaveClass('text-danger');
|
||||||
|
expect(subscriptionCell).toHaveTextContent('No');
|
||||||
|
expect(
|
||||||
|
screen.getByRole('radio', {
|
||||||
|
checked: false,
|
||||||
|
name: props.url,
|
||||||
|
})
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights rows with a slow block height', async () => {
|
||||||
|
const blockHeight = 100;
|
||||||
|
mockHeaders(props.url, { blockHeight });
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
renderComponent(
|
||||||
|
{ ...props, highestBlock: blockHeight + BLOCK_THRESHOLD },
|
||||||
|
statsQueryMock,
|
||||||
|
subMock
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('block-height-cell')).toHaveTextContent(
|
||||||
|
blockHeight.toString()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('block-height-cell')).not.toHaveClass(
|
||||||
|
'text-danger'
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
renderComponent(
|
||||||
|
{ ...props, highestBlock: blockHeight + BLOCK_THRESHOLD + 1 },
|
||||||
|
statsQueryMock,
|
||||||
|
subMock
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('block-height-cell')).toHaveClass('text-danger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables radio button if url is invalid', () => {
|
||||||
|
mockHeaders(props.url, { blockHeight: 100 });
|
||||||
|
|
||||||
|
render(renderComponent(props, statsQueryMock, subMock));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('radio', {
|
||||||
|
checked: false,
|
||||||
|
name: props.url,
|
||||||
|
})
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doesnt render the radio if its the custom row', () => {
|
||||||
|
render(
|
||||||
|
renderComponent(
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
id: CUSTOM_NODE_KEY,
|
||||||
|
},
|
||||||
|
statsQueryMock,
|
||||||
|
subMock
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('radio', {
|
||||||
|
name: props.url,
|
||||||
|
})
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates highest block after new header received', async () => {
|
||||||
|
const mockOnBlockHeight = jest.fn();
|
||||||
|
const blockHeight = 200;
|
||||||
|
mockHeaders(props.url, { blockHeight });
|
||||||
|
render(
|
||||||
|
renderComponent(
|
||||||
|
{ ...props, onBlockHeight: mockOnBlockHeight },
|
||||||
|
statsQueryMock,
|
||||||
|
subMock
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockOnBlockHeight).toHaveBeenCalledWith(blockHeight);
|
||||||
|
});
|
||||||
|
});
|
207
libs/environment/src/components/node-switcher/row-data.tsx
Normal file
207
libs/environment/src/components/node-switcher/row-data.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import type { ApolloError } from '@apollo/client';
|
||||||
|
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||||
|
import { isValidUrl, t } from '@vegaprotocol/react-helpers';
|
||||||
|
import { Radio } from '@vegaprotocol/ui-toolkit';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CUSTOM_NODE_KEY } from '../../types';
|
||||||
|
import {
|
||||||
|
useBlockTimeSubscription,
|
||||||
|
useStatisticsQuery,
|
||||||
|
} from '../../utils/__generated__/Node';
|
||||||
|
import { LayoutCell } from './layout-cell';
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 1000;
|
||||||
|
export const BLOCK_THRESHOLD = 3;
|
||||||
|
|
||||||
|
export interface RowDataProps {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
highestBlock: number | null;
|
||||||
|
onBlockHeight: (blockHeight: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RowData = ({
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
highestBlock,
|
||||||
|
onBlockHeight,
|
||||||
|
}: RowDataProps) => {
|
||||||
|
const [time, setTime] = useState<number>();
|
||||||
|
// no use of data here as we need the data nodes reference to block height
|
||||||
|
const { data, error, loading, startPolling, stopPolling } =
|
||||||
|
useStatisticsQuery({
|
||||||
|
pollInterval: POLL_INTERVAL,
|
||||||
|
// fix for pollInterval
|
||||||
|
// https://github.com/apollographql/apollo-client/issues/9819
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
const headerStore = useHeaderStore();
|
||||||
|
const headers = headerStore[url];
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: subData,
|
||||||
|
error: subError,
|
||||||
|
loading: subLoading,
|
||||||
|
} = useBlockTimeSubscription();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// stop polling if row has errored
|
||||||
|
}, [error, stopPolling]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStartPoll = () => startPolling(POLL_INTERVAL);
|
||||||
|
const handleStopPoll = () => stopPolling();
|
||||||
|
|
||||||
|
window.addEventListener('blur', handleStopPoll);
|
||||||
|
window.addEventListener('focus', handleStartPoll);
|
||||||
|
|
||||||
|
handleStartPoll();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('blur', handleStopPoll);
|
||||||
|
window.removeEventListener('focus', handleStartPoll);
|
||||||
|
};
|
||||||
|
}, [startPolling, stopPolling, error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isValidUrl(url)) return;
|
||||||
|
// every time we get data measure response speed
|
||||||
|
const requestUrl = new URL(url);
|
||||||
|
const requests = window.performance.getEntriesByName(requestUrl.href);
|
||||||
|
const { duration } =
|
||||||
|
(requests.length && requests[requests.length - 1]) || {};
|
||||||
|
setTime(duration);
|
||||||
|
}, [url, data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (headers?.blockHeight) {
|
||||||
|
onBlockHeight(headers.blockHeight);
|
||||||
|
}
|
||||||
|
}, [headers?.blockHeight, onBlockHeight]);
|
||||||
|
|
||||||
|
const getHasError = () => {
|
||||||
|
// the stats query errored
|
||||||
|
if (error) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we are still awaiting a header entry its not an error
|
||||||
|
// we are still waiting for the query to resolve
|
||||||
|
if (!headers) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// highlight this node as 'error' if its more than BLOCK_THRESHOLD blocks behind the most
|
||||||
|
// advanced node
|
||||||
|
if (
|
||||||
|
highestBlock !== null &&
|
||||||
|
headers.blockHeight < highestBlock - BLOCK_THRESHOLD
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIsNodeDisabled = () => {
|
||||||
|
if (!isValidUrl(url)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if still waiting or query errored disable node
|
||||||
|
if (loading || error) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subLoading || subError) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we are still waiting for a header entry for this
|
||||||
|
// url disable the node
|
||||||
|
if (!headers) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{id !== CUSTOM_NODE_KEY && (
|
||||||
|
<div className="break-all" data-testid="node">
|
||||||
|
<Radio
|
||||||
|
id={`node-url-${id}`}
|
||||||
|
value={url}
|
||||||
|
label={url}
|
||||||
|
disabled={getIsNodeDisabled()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<LayoutCell
|
||||||
|
label={t('Response time')}
|
||||||
|
isLoading={!error && loading}
|
||||||
|
hasError={Boolean(error)}
|
||||||
|
dataTestId="response-time-cell"
|
||||||
|
>
|
||||||
|
{getResponseTimeDisplayValue(time, error)}
|
||||||
|
</LayoutCell>
|
||||||
|
<LayoutCell
|
||||||
|
label={t('Block')}
|
||||||
|
isLoading={loading}
|
||||||
|
hasError={getHasError()}
|
||||||
|
dataTestId="block-height-cell"
|
||||||
|
>
|
||||||
|
{getBlockDisplayValue(headers?.blockHeight, error)}
|
||||||
|
</LayoutCell>
|
||||||
|
<LayoutCell
|
||||||
|
label={t('Subscription')}
|
||||||
|
isLoading={subLoading}
|
||||||
|
hasError={Boolean(subError)}
|
||||||
|
dataTestId="subscription-cell"
|
||||||
|
>
|
||||||
|
{getSubscriptionDisplayValue(subData?.busEvents, subError)}
|
||||||
|
</LayoutCell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResponseTimeDisplayValue = (
|
||||||
|
responseTime?: number,
|
||||||
|
error?: ApolloError
|
||||||
|
) => {
|
||||||
|
if (error) {
|
||||||
|
return t('n/a');
|
||||||
|
}
|
||||||
|
if (typeof responseTime === 'number') {
|
||||||
|
return `${Number(responseTime).toFixed(2)}ms`;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBlockDisplayValue = (block?: number, error?: ApolloError) => {
|
||||||
|
if (error) {
|
||||||
|
return t('n/a');
|
||||||
|
}
|
||||||
|
if (block) {
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSubscriptionDisplayValue = (
|
||||||
|
events?: { id: string }[] | null,
|
||||||
|
error?: ApolloError
|
||||||
|
) => {
|
||||||
|
if (error) {
|
||||||
|
return t('No');
|
||||||
|
}
|
||||||
|
if (events?.length) {
|
||||||
|
return t('Yes');
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
};
|
@ -1,22 +0,0 @@
|
|||||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { Networks } from '../types';
|
|
||||||
import { EnvironmentProvider } from './use-environment';
|
|
||||||
|
|
||||||
describe('EnvironmentProvider', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env['NX_MAINTENANCE_PAGE'] = 'true';
|
|
||||||
process.env['NX_VEGA_URL'] = 'https://vega.xyz';
|
|
||||||
process.env['NX_VEGA_ENV'] = Networks.TESTNET;
|
|
||||||
});
|
|
||||||
afterAll(() => {
|
|
||||||
process.env['NX_MAINTENANCE_PAGE'] = '';
|
|
||||||
});
|
|
||||||
it('EnvironmentProvider should return maintenance page', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
render(<EnvironmentProvider />);
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('maintenance-page')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,140 +0,0 @@
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
|
||||||
import type { EnvironmentWithOptionalUrl } from './use-config';
|
|
||||||
import { useConfig } from './use-config';
|
|
||||||
import { Networks, ErrorType } from '../types';
|
|
||||||
|
|
||||||
const mockConfig = {
|
|
||||||
hosts: [
|
|
||||||
'https://vega-host-1.com',
|
|
||||||
'https://vega-host-2.com',
|
|
||||||
'https://vega-host-3.com',
|
|
||||||
'https://vega-host-4.com',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironment: EnvironmentWithOptionalUrl = {
|
|
||||||
VEGA_ENV: Networks.TESTNET,
|
|
||||||
VEGA_CONFIG_URL: 'https://vega.url/config.json',
|
|
||||||
VEGA_NETWORKS: {},
|
|
||||||
ETHEREUM_PROVIDER_URL: 'https://ethereum.provider',
|
|
||||||
ETHERSCAN_URL: 'https://etherscan.url',
|
|
||||||
GIT_BRANCH: 'test',
|
|
||||||
GIT_ORIGIN_URL: 'https://github.com/test/repo',
|
|
||||||
GIT_COMMIT_HASH: 'abcde01234',
|
|
||||||
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
|
|
||||||
};
|
|
||||||
|
|
||||||
function setupFetch(configUrl: string) {
|
|
||||||
return (url: RequestInfo) => {
|
|
||||||
if (url === configUrl) {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve(mockConfig),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
} as Response);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
global.fetch = jest.fn();
|
|
||||||
|
|
||||||
const onError = jest.fn();
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
onError.mockClear();
|
|
||||||
window.localStorage.clear();
|
|
||||||
|
|
||||||
// @ts-ignore typescript doesn't recognise the mocked instance
|
|
||||||
global.fetch.mockReset();
|
|
||||||
// @ts-ignore typescript doesn't recognise the mocked instance
|
|
||||||
global.fetch.mockImplementation(
|
|
||||||
setupFetch(mockEnvironment.VEGA_CONFIG_URL ?? '')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useConfig hook', () => {
|
|
||||||
it("doesn't update when there is no VEGA_CONFIG_URL in the environment", async () => {
|
|
||||||
const mockEnvWithoutUrl = {
|
|
||||||
...mockEnvironment,
|
|
||||||
VEGA_CONFIG_URL: undefined,
|
|
||||||
};
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useConfig({ environment: mockEnvWithoutUrl }, onError)
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.config).toBe(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches configuration from the provided url', async () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useConfig({ environment: mockEnvironment }, onError)
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(fetch).toHaveBeenCalledWith(mockEnvironment.VEGA_CONFIG_URL);
|
|
||||||
expect(result.current.config).toEqual(mockConfig);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('executes the error callback when the config endpoint fails', async () => {
|
|
||||||
// @ts-ignore typescript doesn't recognise the mocked instance
|
|
||||||
global.fetch.mockImplementation(() => Promise.reject());
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useConfig({ environment: mockEnvironment }, onError)
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.config).toEqual({ hosts: [] });
|
|
||||||
expect(onError).toHaveBeenCalledWith(ErrorType.CONFIG_LOAD_ERROR);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('executes the error callback when the config validation fails', async () => {
|
|
||||||
// @ts-ignore typescript doesn't recognise the mocked instance
|
|
||||||
global.fetch.mockImplementation(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ data: 'not-valid-config' }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useConfig({ environment: mockEnvironment }, onError)
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.config).toBe(undefined);
|
|
||||||
expect(onError).toHaveBeenCalledWith(ErrorType.CONFIG_VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the default config without getting it from the network when provided', async () => {
|
|
||||||
const defaultConfig = { hosts: ['https://default.url'] };
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useConfig(
|
|
||||||
{
|
|
||||||
environment: mockEnvironment,
|
|
||||||
defaultConfig,
|
|
||||||
},
|
|
||||||
onError
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.config).toBe(defaultConfig);
|
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,72 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { ErrorType } from '../types';
|
|
||||||
import type { Environment, Configuration } from '../types';
|
|
||||||
import { validateConfiguration } from '../utils/validate-configuration';
|
|
||||||
|
|
||||||
export type EnvironmentWithOptionalUrl = Partial<Environment> &
|
|
||||||
Omit<Environment, 'VEGA_URL'>;
|
|
||||||
|
|
||||||
const compileHosts = (hosts: string[], envUrl?: string) => {
|
|
||||||
if (envUrl && !hosts.includes(envUrl)) {
|
|
||||||
return [...hosts, envUrl];
|
|
||||||
}
|
|
||||||
return hosts;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UseConfigOptions = {
|
|
||||||
environment: EnvironmentWithOptionalUrl;
|
|
||||||
defaultConfig?: Configuration;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch list of hosts from the VEGA_CONFIG_URL
|
|
||||||
*/
|
|
||||||
export const useConfig = (
|
|
||||||
{ environment, defaultConfig }: UseConfigOptions,
|
|
||||||
onError: (errorType: ErrorType) => void
|
|
||||||
) => {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [config, setConfig] = useState<Configuration | undefined>(
|
|
||||||
defaultConfig
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
(async () => {
|
|
||||||
if (!config && environment.VEGA_CONFIG_URL) {
|
|
||||||
isMounted && setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(environment.VEGA_CONFIG_URL);
|
|
||||||
const configData: Configuration = await response.json();
|
|
||||||
|
|
||||||
if (validateConfiguration(configData)) {
|
|
||||||
onError(ErrorType.CONFIG_VALIDATION_ERROR);
|
|
||||||
isMounted && setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hosts = compileHosts(configData.hosts, environment.VEGA_URL);
|
|
||||||
|
|
||||||
isMounted && setConfig({ hosts });
|
|
||||||
isMounted && setLoading(false);
|
|
||||||
} catch (err) {
|
|
||||||
if (isMounted) {
|
|
||||||
setLoading(false);
|
|
||||||
setConfig({ hosts: [] });
|
|
||||||
}
|
|
||||||
onError(ErrorType.CONFIG_LOAD_ERROR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
// load config only once per runtime
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [environment.VEGA_CONFIG_URL, !!config, onError, setLoading]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
loading,
|
|
||||||
config,
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,218 +0,0 @@
|
|||||||
// having the node switcher dialog in the environment provider breaks the test renderer
|
|
||||||
// workaround based on: https://github.com/facebook/react/issues/11565
|
|
||||||
import type { ComponentProps, ReactNode } from 'react';
|
|
||||||
import { renderHook } from '@testing-library/react';
|
|
||||||
import { createClient } from '@vegaprotocol/apollo-client';
|
|
||||||
import { useEnvironment, EnvironmentProvider } from './use-environment';
|
|
||||||
import { Networks } from '../types';
|
|
||||||
import createMockClient from './mocks/apollo-client';
|
|
||||||
jest.mock('@vegaprotocol/apollo-client');
|
|
||||||
|
|
||||||
jest.mock('react-dom', () => ({
|
|
||||||
...jest.requireActual('react-dom'),
|
|
||||||
createPortal: (node: ReactNode) => node,
|
|
||||||
}));
|
|
||||||
|
|
||||||
global.fetch = jest.fn();
|
|
||||||
|
|
||||||
const MockWrapper = (props: ComponentProps<typeof EnvironmentProvider>) => {
|
|
||||||
return <EnvironmentProvider {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_HOST = 'https://vega.host/query';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
const mockEnvironmentState = {
|
|
||||||
VEGA_URL: 'https://vega.xyz',
|
|
||||||
VEGA_ENV: Networks.TESTNET,
|
|
||||||
VEGA_CONFIG_URL: 'https://vega.xyz/testnet-config.json',
|
|
||||||
VEGA_NETWORKS: {
|
|
||||||
TESTNET: 'https://testnet.url',
|
|
||||||
STAGNET: 'https://stagnet.url',
|
|
||||||
MAINNET: 'https://mainnet.url',
|
|
||||||
},
|
|
||||||
ETHEREUM_PROVIDER_URL: 'https://ether.provider',
|
|
||||||
ETHERSCAN_URL: 'https://etherscan.url',
|
|
||||||
GIT_BRANCH: 'test',
|
|
||||||
GIT_ORIGIN_URL: 'https://github.com/test/repo',
|
|
||||||
GIT_COMMIT_HASH: 'abcde01234',
|
|
||||||
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
|
|
||||||
setNodeSwitcherOpen: noop,
|
|
||||||
networkError: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_DURATION = 76;
|
|
||||||
|
|
||||||
window.performance.getEntriesByName = jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation((url: string) => [
|
|
||||||
{
|
|
||||||
entryType: 'resource',
|
|
||||||
name: url,
|
|
||||||
startTime: 0,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
duration: MOCK_DURATION,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
function setupFetch(
|
|
||||||
configUrl: string = mockEnvironmentState.VEGA_CONFIG_URL,
|
|
||||||
hosts?: string[]
|
|
||||||
) {
|
|
||||||
return (url: RequestInfo) => {
|
|
||||||
if (url === configUrl) {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ hosts: hosts || [MOCK_HOST] }),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
} as Response);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// @ts-ignore: typescript doesn't recognize the mock implementation
|
|
||||||
global.fetch.mockImplementation(setupFetch());
|
|
||||||
|
|
||||||
window.localStorage.clear();
|
|
||||||
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation(() => createMockClient());
|
|
||||||
|
|
||||||
process.env['NX_VEGA_URL'] = mockEnvironmentState.VEGA_URL;
|
|
||||||
process.env['NX_VEGA_ENV'] = mockEnvironmentState.VEGA_ENV;
|
|
||||||
process.env['NX_VEGA_CONFIG_URL'] = mockEnvironmentState.VEGA_CONFIG_URL;
|
|
||||||
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(
|
|
||||||
mockEnvironmentState.VEGA_NETWORKS
|
|
||||||
);
|
|
||||||
process.env['NX_ETHEREUM_PROVIDER_URL'] =
|
|
||||||
mockEnvironmentState.ETHEREUM_PROVIDER_URL;
|
|
||||||
process.env['NX_ETHERSCAN_URL'] = mockEnvironmentState.ETHERSCAN_URL;
|
|
||||||
process.env['NX_GIT_BRANCH'] = mockEnvironmentState.GIT_BRANCH;
|
|
||||||
process.env['NX_GIT_ORIGIN_URL'] = mockEnvironmentState.GIT_ORIGIN_URL;
|
|
||||||
process.env['NX_GIT_COMMIT_HASH'] = mockEnvironmentState.GIT_COMMIT_HASH;
|
|
||||||
process.env['NX_GITHUB_FEEDBACK_URL'] =
|
|
||||||
mockEnvironmentState.GITHUB_FEEDBACK_URL;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('throws error', () => {
|
|
||||||
const consoleError = console.error;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
console.error = jest.fn();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
console.error = consoleError;
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// @ts-ignore: typescript doesn't recognize the mock implementation
|
|
||||||
global.fetch.mockImplementation(setupFetch());
|
|
||||||
|
|
||||||
window.localStorage.clear();
|
|
||||||
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation(() => createMockClient());
|
|
||||||
|
|
||||||
process.env['NX_VEGA_URL'] = mockEnvironmentState.VEGA_URL;
|
|
||||||
process.env['NX_VEGA_ENV'] = mockEnvironmentState.VEGA_ENV;
|
|
||||||
process.env['NX_VEGA_CONFIG_URL'] = mockEnvironmentState.VEGA_CONFIG_URL;
|
|
||||||
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(
|
|
||||||
mockEnvironmentState.VEGA_NETWORKS
|
|
||||||
);
|
|
||||||
process.env['NX_ETHEREUM_PROVIDER_URL'] =
|
|
||||||
mockEnvironmentState.ETHEREUM_PROVIDER_URL;
|
|
||||||
process.env['NX_ETHERSCAN_URL'] = mockEnvironmentState.ETHERSCAN_URL;
|
|
||||||
process.env['NX_GIT_BRANCH'] = mockEnvironmentState.GIT_BRANCH;
|
|
||||||
process.env['NX_GIT_ORIGIN_URL'] = mockEnvironmentState.GIT_ORIGIN_URL;
|
|
||||||
process.env['NX_GIT_COMMIT_HASH'] = mockEnvironmentState.GIT_COMMIT_HASH;
|
|
||||||
process.env['NX_GITHUB_FEEDBACK_URL'] =
|
|
||||||
mockEnvironmentState.GITHUB_FEEDBACK_URL;
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => jest.resetModules()); // clears the cache of the modules
|
|
||||||
|
|
||||||
it('throws a validation error when NX_ETHERSCAN_URL is not a valid url', () => {
|
|
||||||
process.env['NX_ETHERSCAN_URL'] = 'invalid-url';
|
|
||||||
const result = () =>
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
expect(result).toThrow(
|
|
||||||
`The NX_ETHERSCAN_URL environment variable must be a valid url`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a validation error when NX_ETHEREUM_PROVIDER_URL is not a valid url', () => {
|
|
||||||
process.env['NX_ETHEREUM_PROVIDER_URL'] = 'invalid-url';
|
|
||||||
const result = () =>
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
expect(result).toThrow(
|
|
||||||
`The NX_ETHEREUM_PROVIDER_URL environment variable must be a valid url`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a validation error when VEGA_NETWORKS has an invalid network as a key', () => {
|
|
||||||
process.env['NX_VEGA_NETWORKS'] = JSON.stringify({
|
|
||||||
NOT_A_NETWORK: 'https://somewhere.url',
|
|
||||||
});
|
|
||||||
const result = () =>
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
expect(result).toThrow(
|
|
||||||
`Error processing the vega app environment:
|
|
||||||
- All keys in NX_VEGA_NETWORKS must represent a valid environment: ${Object.keys(
|
|
||||||
Networks
|
|
||||||
).join(' | ')}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a validation error when both VEGA_URL and VEGA_CONFIG_URL are missing in the environment', () => {
|
|
||||||
delete process.env['NX_VEGA_URL'];
|
|
||||||
delete process.env['NX_VEGA_CONFIG_URL'];
|
|
||||||
const result = () =>
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
expect(result).toThrow(
|
|
||||||
`Must provide either NX_VEGA_CONFIG_URL or NX_VEGA_URL in the environment.`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a validation error when NX_VEGA_ENV is not found in the environment', () => {
|
|
||||||
delete process.env['NX_VEGA_ENV'];
|
|
||||||
const result = () =>
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
expect(result).toThrow(
|
|
||||||
`Error processing the vega app environment:
|
|
||||||
- NX_VEGA_ENV is invalid, received "undefined" instead of: '${Object.keys(
|
|
||||||
Networks
|
|
||||||
).join("' | '")}'`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a validation error when VEGA_ENV is not a valid network', () => {
|
|
||||||
process.env['NX_VEGA_ENV'] = 'SOMETHING';
|
|
||||||
const result = () =>
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
expect(result).not.toThrow(
|
|
||||||
`Error processing the vega app environment:
|
|
||||||
- NX_VEGA_ENV is invalid, received "SOMETHING" instead of: '${Object.keys(
|
|
||||||
Networks
|
|
||||||
).join("' | '")}'`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
259
libs/environment/src/hooks/use-environment.spec.ts
Normal file
259
libs/environment/src/hooks/use-environment.spec.ts
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { Networks } from '../types';
|
||||||
|
import { useEnvironment } from './use-environment';
|
||||||
|
|
||||||
|
const noop = () => {
|
||||||
|
/* no op*/
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('@vegaprotocol/apollo-client', () => ({
|
||||||
|
createClient: () => ({
|
||||||
|
query: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: {
|
||||||
|
statistics: {
|
||||||
|
chainId: 'chain-id',
|
||||||
|
blockHeight: '100',
|
||||||
|
vegaTime: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
subscribe: () => ({
|
||||||
|
// eslint-disable-next-line
|
||||||
|
subscribe: (obj: any) => {
|
||||||
|
obj.next();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
jest.mock('zustand');
|
||||||
|
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
const setupFetch = (result: any) => {
|
||||||
|
return () => {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(result),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEnvVars = {
|
||||||
|
VEGA_ENV: Networks.TESTNET,
|
||||||
|
VEGA_NETWORKS: {
|
||||||
|
DEVNET: 'https://devnet.url',
|
||||||
|
TESTNET: 'https://testnet.url',
|
||||||
|
STAGNET3: 'https://stagnet3.url',
|
||||||
|
MAINNET: 'https://mainnet.url',
|
||||||
|
},
|
||||||
|
VEGA_WALLET_URL: 'https://localhost:1234',
|
||||||
|
ETHEREUM_PROVIDER_URL: 'https://ether.provider',
|
||||||
|
ETHERSCAN_URL: 'https://etherscan.url',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useEnvironment', () => {
|
||||||
|
const env = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
process.env = { ...env };
|
||||||
|
|
||||||
|
process.env['NX_VEGA_ENV'] = mockEnvVars.VEGA_ENV;
|
||||||
|
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(mockEnvVars.VEGA_NETWORKS);
|
||||||
|
process.env['NX_ETHEREUM_PROVIDER_URL'] = mockEnvVars.ETHEREUM_PROVIDER_URL;
|
||||||
|
process.env['NX_VEGA_WALLET_URL'] = mockEnvVars.VEGA_WALLET_URL;
|
||||||
|
process.env['NX_ETHERSCAN_URL'] = mockEnvVars.ETHERSCAN_URL;
|
||||||
|
|
||||||
|
// if config is fetched resulting suitable node
|
||||||
|
// will be stored in localStorage
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
// @ts-ignore clear mocked node config fetch
|
||||||
|
fetch.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = env;
|
||||||
|
});
|
||||||
|
|
||||||
|
const setup = () => {
|
||||||
|
return renderHook(() => useEnvironment());
|
||||||
|
};
|
||||||
|
|
||||||
|
it('exposes env vars and sets VEGA_URL from config nodes', async () => {
|
||||||
|
const configUrl = 'https://vega.xyz/testnet-config.json';
|
||||||
|
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
|
||||||
|
const nodes = [
|
||||||
|
'https://api.n00.foo.vega.xyz',
|
||||||
|
'https://api.n01.foo.vega.xyz',
|
||||||
|
];
|
||||||
|
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||||
|
global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
|
||||||
|
const { result } = setup();
|
||||||
|
|
||||||
|
expect(result.current.status).toBe('default');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.status).toBe('pending');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.status).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
// resulting VEGA_URL should be one of the nodes from the config
|
||||||
|
expect(
|
||||||
|
result.current.VEGA_URL === nodes[0] ||
|
||||||
|
result.current.VEGA_URL === nodes[1]
|
||||||
|
).toBe(true);
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
...mockEnvVars,
|
||||||
|
nodes,
|
||||||
|
});
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(configUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets error if environment is invalid', async () => {
|
||||||
|
const error = console.error;
|
||||||
|
console.error = noop;
|
||||||
|
process.env['NX_VEGA_ENV'] = undefined; // VEGA_ENV is required by zod schema
|
||||||
|
const { result } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
result.current.initialize();
|
||||||
|
});
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
error: 'Error processing the Vega environment',
|
||||||
|
});
|
||||||
|
console.error = error;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors if neither VEGA_URL or VEGA_CONFIG_URL are set', async () => {
|
||||||
|
const error = console.error;
|
||||||
|
console.error = noop;
|
||||||
|
process.env['NX_VEGA_ENV'] = undefined;
|
||||||
|
process.env['NX_VEGA_URL'] = undefined;
|
||||||
|
const { result } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
result.current.initialize();
|
||||||
|
});
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
console.error = error;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows for undefined VEGA_CONFIG_URL if VEGA_URL is set', async () => {
|
||||||
|
const url = 'https://my.vega.url';
|
||||||
|
process.env['NX_VEGA_URL'] = url;
|
||||||
|
process.env['NX_VEGA_CONFIG_URL'] = undefined;
|
||||||
|
const { result } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
result.current.initialize();
|
||||||
|
});
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
VEGA_URL: url,
|
||||||
|
VEGA_CONFIG_URL: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows for undefined VEGA_URL if VEGA_CONFIG_URL is set', async () => {
|
||||||
|
const configUrl = 'https://vega.xyz/testnet-config.json';
|
||||||
|
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
|
||||||
|
process.env['NX_VEGA_URL'] = undefined;
|
||||||
|
const nodes = [
|
||||||
|
'https://api.n00.foo.vega.xyz',
|
||||||
|
'https://api.n01.foo.vega.xyz',
|
||||||
|
];
|
||||||
|
// @ts-ignore setup mock fetch for config url
|
||||||
|
global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
|
||||||
|
const { result } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
result.current.initialize();
|
||||||
|
});
|
||||||
|
expect(typeof result.current.VEGA_URL).toEqual('string');
|
||||||
|
expect(result.current.VEGA_URL).not.toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles error if node config cannot be fetched', async () => {
|
||||||
|
const warn = console.warn;
|
||||||
|
console.warn = noop;
|
||||||
|
const configUrl = 'https://vega.xyz/testnet-config.json';
|
||||||
|
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
|
||||||
|
process.env['NX_VEGA_URL'] = undefined;
|
||||||
|
// @ts-ignore setup mock fetch for config url
|
||||||
|
global.fetch.mockImplementation(() => {
|
||||||
|
throw new Error('failed to fetch');
|
||||||
|
});
|
||||||
|
const { result } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
result.current.initialize();
|
||||||
|
});
|
||||||
|
expect(result.current.status).toEqual('failed');
|
||||||
|
expect(typeof result.current.error).toBe('string');
|
||||||
|
expect(result.current.error).toBeTruthy();
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(configUrl);
|
||||||
|
console.warn = warn;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an invalid node config', async () => {
|
||||||
|
const warn = console.warn;
|
||||||
|
console.warn = noop;
|
||||||
|
const configUrl = 'https://vega.xyz/testnet-config.json';
|
||||||
|
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
|
||||||
|
process.env['NX_VEGA_URL'] = undefined;
|
||||||
|
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||||
|
global.fetch.mockImplementation(setupFetch({ invalid: 'invalid' }));
|
||||||
|
const { result } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
result.current.initialize();
|
||||||
|
});
|
||||||
|
expect(result.current.status).toEqual('failed');
|
||||||
|
expect(typeof result.current.error).toBe('string');
|
||||||
|
expect(result.current.error).toBeTruthy();
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(configUrl);
|
||||||
|
console.warn = warn;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses stored url', async () => {
|
||||||
|
const configUrl = 'https://vega.xyz/testnet-config.json';
|
||||||
|
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
|
||||||
|
// @ts-ignore setup mock fetch for config url
|
||||||
|
global.fetch.mockImplementation(
|
||||||
|
setupFetch({ hosts: ['http://foo.bar.com'] })
|
||||||
|
);
|
||||||
|
const url = 'https://api.n00.foo.com';
|
||||||
|
localStorage.setItem('vega_url', url);
|
||||||
|
const { result } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
result.current.initialize();
|
||||||
|
});
|
||||||
|
expect(result.current.VEGA_URL).toBe(url);
|
||||||
|
expect(result.current.status).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can update VEGA_URL', async () => {
|
||||||
|
const url = 'https://api.n00.foo.com';
|
||||||
|
const newUrl = 'http://foo.bar.com';
|
||||||
|
process.env['NX_VEGA_URL'] = url;
|
||||||
|
const { result } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
result.current.initialize();
|
||||||
|
});
|
||||||
|
expect(result.current.VEGA_URL).toBe(url);
|
||||||
|
expect(result.current.status).toBe('success');
|
||||||
|
act(() => {
|
||||||
|
result.current.setUrl(newUrl);
|
||||||
|
});
|
||||||
|
expect(result.current.VEGA_URL).toBe(newUrl);
|
||||||
|
expect(localStorage.getItem('vega_url')).toBe(newUrl);
|
||||||
|
});
|
||||||
|
});
|
@ -1,609 +0,0 @@
|
|||||||
// having the node switcher dialog in the environment provider breaks the test renderer
|
|
||||||
// workaround based on: https://github.com/facebook/react/issues/11565
|
|
||||||
import type { ComponentProps, ReactNode } from 'react';
|
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
|
||||||
import type { ClientOptions } from '@vegaprotocol/apollo-client';
|
|
||||||
import { createClient } from '@vegaprotocol/apollo-client';
|
|
||||||
import { useEnvironment, EnvironmentProvider } from './use-environment';
|
|
||||||
import { Networks, ErrorType } from '../types';
|
|
||||||
import type { MockRequestConfig } from './mocks/apollo-client';
|
|
||||||
import createMockClient from './mocks/apollo-client';
|
|
||||||
import { getErrorByType } from '../utils/validate-node';
|
|
||||||
|
|
||||||
jest.mock('@vegaprotocol/apollo-client');
|
|
||||||
|
|
||||||
jest.mock('react-dom', () => ({
|
|
||||||
...jest.requireActual('react-dom'),
|
|
||||||
createPortal: (node: ReactNode) => node,
|
|
||||||
}));
|
|
||||||
|
|
||||||
global.fetch = jest.fn();
|
|
||||||
|
|
||||||
const MockWrapper = (props: ComponentProps<typeof EnvironmentProvider>) => {
|
|
||||||
return <EnvironmentProvider {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_HOST = 'https://vega.host/query';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
const mockEnvironmentState = {
|
|
||||||
VEGA_URL: 'https://vega.xyz',
|
|
||||||
VEGA_ENV: Networks.TESTNET,
|
|
||||||
VEGA_CONFIG_URL: 'https://vega.xyz/testnet-config.json',
|
|
||||||
VEGA_NETWORKS: {
|
|
||||||
DEVNET: 'https://devnet.url',
|
|
||||||
TESTNET: 'https://testnet.url',
|
|
||||||
STAGNET3: 'https://stagnet3.url',
|
|
||||||
MAINNET: 'https://mainnet.url',
|
|
||||||
},
|
|
||||||
ETHEREUM_PROVIDER_URL: 'https://ether.provider',
|
|
||||||
ETHERSCAN_URL: 'https://etherscan.url',
|
|
||||||
GIT_BRANCH: 'test',
|
|
||||||
GIT_ORIGIN_URL: 'https://github.com/test/repo',
|
|
||||||
GIT_COMMIT_HASH: 'abcde01234',
|
|
||||||
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
|
|
||||||
MAINTENANCE_PAGE: false,
|
|
||||||
configLoading: false,
|
|
||||||
blockDifference: 0,
|
|
||||||
nodeSwitcherOpen: false,
|
|
||||||
setNodeSwitcherOpen: noop,
|
|
||||||
networkError: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_DURATION = 76;
|
|
||||||
|
|
||||||
window.performance.getEntriesByName = jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation((url: string) => [
|
|
||||||
{
|
|
||||||
entryType: 'resource',
|
|
||||||
name: url,
|
|
||||||
startTime: 0,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
duration: MOCK_DURATION,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
function setupFetch(
|
|
||||||
configUrl: string = mockEnvironmentState.VEGA_CONFIG_URL,
|
|
||||||
hosts?: string[]
|
|
||||||
) {
|
|
||||||
return (url: RequestInfo) => {
|
|
||||||
if (url === configUrl) {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ hosts: hosts || [MOCK_HOST] }),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
} as Response);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const getQuickestNode = (mockNodes: Record<string, MockRequestConfig>) => {
|
|
||||||
const { nodeUrl } = Object.keys(mockNodes).reduce<{
|
|
||||||
nodeUrl?: string;
|
|
||||||
delay: number;
|
|
||||||
}>(
|
|
||||||
(acc, url) => {
|
|
||||||
const { delay = 0, hasError = false } = mockNodes[url];
|
|
||||||
if (!hasError && delay < acc.delay) {
|
|
||||||
return { nodeUrl: url, delay };
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ nodeUrl: undefined, delay: Infinity }
|
|
||||||
);
|
|
||||||
return nodeUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
|
||||||
global.fetch.mockImplementation(setupFetch());
|
|
||||||
|
|
||||||
window.localStorage.clear();
|
|
||||||
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation(() => createMockClient());
|
|
||||||
|
|
||||||
process.env['NX_VEGA_URL'] = mockEnvironmentState.VEGA_URL;
|
|
||||||
process.env['NX_VEGA_ENV'] = mockEnvironmentState.VEGA_ENV;
|
|
||||||
process.env['NX_VEGA_CONFIG_URL'] = mockEnvironmentState.VEGA_CONFIG_URL;
|
|
||||||
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(
|
|
||||||
mockEnvironmentState.VEGA_NETWORKS
|
|
||||||
);
|
|
||||||
process.env['NX_ETHEREUM_PROVIDER_URL'] =
|
|
||||||
mockEnvironmentState.ETHEREUM_PROVIDER_URL;
|
|
||||||
process.env['NX_ETHERSCAN_URL'] = mockEnvironmentState.ETHERSCAN_URL;
|
|
||||||
process.env['NX_GIT_BRANCH'] = mockEnvironmentState.GIT_BRANCH;
|
|
||||||
process.env['NX_GIT_ORIGIN_URL'] = mockEnvironmentState.GIT_ORIGIN_URL;
|
|
||||||
process.env['NX_GIT_COMMIT_HASH'] = mockEnvironmentState.GIT_COMMIT_HASH;
|
|
||||||
process.env['NX_GITHUB_FEEDBACK_URL'] =
|
|
||||||
mockEnvironmentState.GITHUB_FEEDBACK_URL;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useEnvironment hook', () => {
|
|
||||||
it('transforms and exposes values from the environment', async () => {
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows for the VEGA_CONFIG_URL to be missing when there is a VEGA_URL present', async () => {
|
|
||||||
delete process.env['NX_VEGA_CONFIG_URL'];
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
VEGA_CONFIG_URL: undefined,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows for the VEGA_NETWORKS to be missing from the environment', async () => {
|
|
||||||
delete process.env['NX_VEGA_NETWORKS'];
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
VEGA_NETWORKS: {
|
|
||||||
TESTNET: window.location.origin,
|
|
||||||
},
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a validation error when NX_VEGA_ENV is not found in the environment', async () => {
|
|
||||||
delete process.env['NX_VEGA_ENV'];
|
|
||||||
const consoleError = console.error;
|
|
||||||
console.error = noop;
|
|
||||||
expect(() => {
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
}).toThrowError(
|
|
||||||
`NX_VEGA_ENV is invalid, received "undefined" instead of: 'CUSTOM' | 'SANDBOX' | 'TESTNET' | 'STAGNET1' | 'STAGNET3' | 'DEVNET' | 'MAINNET' | 'MIRROR'`
|
|
||||||
);
|
|
||||||
console.error = consoleError;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a validation error when VEGA_ENV is not a valid network', async () => {
|
|
||||||
process.env['NX_VEGA_ENV'] = 'SOMETHING';
|
|
||||||
const consoleError = console.error;
|
|
||||||
console.error = noop;
|
|
||||||
expect(() => {
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
}).toThrowError(
|
|
||||||
`NX_VEGA_ENV is invalid, received "SOMETHING" instead of: CUSTOM | SANDBOX | TESTNET | STAGNET1 | STAGNET3 | DEVNET | MAINNET | MIRROR`
|
|
||||||
);
|
|
||||||
console.error = consoleError;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('when VEGA_NETWORKS is not a valid json, prints a warning and continues without using the value from it', async () => {
|
|
||||||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(noop);
|
|
||||||
process.env['NX_VEGA_NETWORKS'] = '{not:{valid:json';
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
VEGA_NETWORKS: {
|
|
||||||
TESTNET: window.location.origin,
|
|
||||||
},
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
|
||||||
consoleWarnSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a validation error when VEGA_NETWORKS has an invalid network as a key', async () => {
|
|
||||||
process.env['NX_VEGA_NETWORKS'] = JSON.stringify({
|
|
||||||
NOT_A_NETWORK: 'https://somewhere.url',
|
|
||||||
});
|
|
||||||
const consoleError = console.error;
|
|
||||||
console.error = noop;
|
|
||||||
expect(() => {
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
}).toThrowError(
|
|
||||||
`All keys in NX_VEGA_NETWORKS must represent a valid environment: CUSTOM | SANDBOX | TESTNET | STAGNET1 | STAGNET3 | DEVNET | MAINNET | MIRROR`
|
|
||||||
);
|
|
||||||
console.error = consoleError;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a validation error when both VEGA_URL and VEGA_CONFIG_URL are missing in the environment', async () => {
|
|
||||||
delete process.env['NX_VEGA_URL'];
|
|
||||||
delete process.env['NX_VEGA_CONFIG_URL'];
|
|
||||||
const consoleError = console.error;
|
|
||||||
console.error = noop;
|
|
||||||
expect(() => {
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
}).toThrowError(
|
|
||||||
`Must provide either NX_VEGA_CONFIG_URL or NX_VEGA_URL in the environment.`
|
|
||||||
);
|
|
||||||
console.error = consoleError;
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each`
|
|
||||||
env | etherscanUrl | providerUrl
|
|
||||||
${Networks.DEVNET} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
|
|
||||||
${Networks.TESTNET} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
|
|
||||||
${Networks.STAGNET3} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
|
|
||||||
${Networks.MAINNET} | ${'https://etherscan.io'} | ${'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
|
|
||||||
`(
|
|
||||||
'uses correct default ethereum connection variables in $env',
|
|
||||||
async ({ env, etherscanUrl, providerUrl }) => {
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation(() => createMockClient({ network: env }));
|
|
||||||
|
|
||||||
process.env['NX_VEGA_ENV'] = env;
|
|
||||||
delete process.env['NX_ETHEREUM_PROVIDER_URL'];
|
|
||||||
delete process.env['NX_ETHERSCAN_URL'];
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
VEGA_ENV: env,
|
|
||||||
ETHEREUM_PROVIDER_URL: providerUrl,
|
|
||||||
ETHERSCAN_URL: etherscanUrl,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
it('throws a validation error when NX_ETHERSCAN_URL is not a valid url', async () => {
|
|
||||||
process.env['NX_ETHERSCAN_URL'] = 'invalid-url';
|
|
||||||
const consoleError = console.error;
|
|
||||||
console.error = noop;
|
|
||||||
expect(() => {
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
}).toThrowError(
|
|
||||||
`The NX_ETHERSCAN_URL environment variable must be a valid url`
|
|
||||||
);
|
|
||||||
console.error = consoleError;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a validation error when NX_ETHEREUM_PROVIDER_URL is not a valid url', async () => {
|
|
||||||
process.env['NX_ETHEREUM_PROVIDER_URL'] = 'invalid-url';
|
|
||||||
const consoleError = console.error;
|
|
||||||
console.error = noop;
|
|
||||||
expect(() => {
|
|
||||||
renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
}).toThrow(
|
|
||||||
`The NX_ETHEREUM_PROVIDER_URL environment variable must be a valid url`
|
|
||||||
);
|
|
||||||
console.error = consoleError;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('node selection', () => {
|
|
||||||
it('updates the VEGA_URL from the config when it is missing from the environment', async () => {
|
|
||||||
delete process.env['NX_VEGA_URL'];
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
VEGA_URL: MOCK_HOST,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line jest/no-disabled-tests
|
|
||||||
it.skip('updates the VEGA_URL with the quickest node to respond from the config urls', async () => {
|
|
||||||
delete process.env['NX_VEGA_URL'];
|
|
||||||
|
|
||||||
const mockNodes: Record<string, MockRequestConfig> = {
|
|
||||||
'https://mock-node-1.com': { hasError: false, delay: 4 },
|
|
||||||
'https://mock-node-2.com': { hasError: false, delay: 5 },
|
|
||||||
'https://mock-node-3.com': { hasError: false, delay: 8 },
|
|
||||||
'https://mock-node-4.com': { hasError: false, delay: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
|
||||||
global.fetch.mockImplementation(
|
|
||||||
setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes))
|
|
||||||
);
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation((cfg: ClientOptions) => {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
return createMockClient({ statistics: mockNodes[cfg.url!] });
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodeUrl = getQuickestNode(mockNodes);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
VEGA_URL: nodeUrl,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores failing nodes and selects the first successful one to use', async () => {
|
|
||||||
delete process.env['NX_VEGA_URL'];
|
|
||||||
|
|
||||||
const mockNodes: Record<string, MockRequestConfig> = {
|
|
||||||
'https://mock-node-1.com': { hasError: true, delay: 4 },
|
|
||||||
'https://mock-node-2.com': { hasError: false, delay: 5 },
|
|
||||||
'https://mock-node-3.com': { hasError: false, delay: 8 },
|
|
||||||
'https://mock-node-4.com': { hasError: true, delay: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
|
||||||
global.fetch.mockImplementation(
|
|
||||||
setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes))
|
|
||||||
);
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation((cfg: ClientOptions) => {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
return createMockClient({ statistics: mockNodes[cfg.url!] });
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodeUrl = getQuickestNode(mockNodes);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
VEGA_URL: nodeUrl,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has a network error when cannot connect to any nodes', async () => {
|
|
||||||
delete process.env['NX_VEGA_URL'];
|
|
||||||
|
|
||||||
const mockNodes: Record<string, MockRequestConfig> = {
|
|
||||||
'https://mock-node-1.com': { hasError: true, delay: 4 },
|
|
||||||
'https://mock-node-2.com': { hasError: true, delay: 5 },
|
|
||||||
'https://mock-node-3.com': { hasError: true, delay: 8 },
|
|
||||||
'https://mock-node-4.com': { hasError: true, delay: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
|
||||||
global.fetch.mockImplementation(
|
|
||||||
setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes))
|
|
||||||
);
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation((cfg: ClientOptions) => {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
return createMockClient({ statistics: mockNodes[cfg.url!] });
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
VEGA_URL: undefined,
|
|
||||||
networkError: ErrorType.CONNECTION_ERROR_ALL,
|
|
||||||
nodeSwitcherOpen: true,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has a network error when it cannot fetch the network config and there is no VEGA_URL in the environment', async () => {
|
|
||||||
delete process.env['NX_VEGA_URL'];
|
|
||||||
|
|
||||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
|
||||||
global.fetch.mockImplementation(() => {
|
|
||||||
throw new Error('Cannot fetch');
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
VEGA_URL: undefined,
|
|
||||||
networkError: ErrorType.CONFIG_LOAD_ERROR,
|
|
||||||
nodeSwitcherOpen: true,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('logs an error when it cannot fetch the network config and there is a VEGA_URL in the environment', async () => {
|
|
||||||
const consoleWarnSpy = jest
|
|
||||||
.spyOn(console, 'warn')
|
|
||||||
.mockImplementation(noop);
|
|
||||||
|
|
||||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
|
||||||
global.fetch.mockImplementation(() => {
|
|
||||||
throw new Error('Cannot fetch');
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
nodeSwitcherOpen: false,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
||||||
getErrorByType(
|
|
||||||
ErrorType.CONFIG_LOAD_ERROR,
|
|
||||||
mockEnvironmentState.VEGA_ENV
|
|
||||||
)?.headline
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// SKIP due to https://github.com/facebook/jest/issues/12670
|
|
||||||
// eslint-disable-next-line jest/no-disabled-tests
|
|
||||||
it.skip('has a network error when the config is invalid and there is no VEGA_URL in the environment', async () => {
|
|
||||||
delete process.env['NX_VEGA_URL'];
|
|
||||||
|
|
||||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
|
||||||
global.fetch.mockImplementation(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ some: 'invalid-object' }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
VEGA_URL: undefined,
|
|
||||||
networkError: ErrorType.CONFIG_VALIDATION_ERROR,
|
|
||||||
nodeSwitcherOpen: true,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// SKIP due to https://github.com/facebook/jest/issues/12670
|
|
||||||
// eslint-disable-next-line jest/no-disabled-tests
|
|
||||||
it.skip('logs an error when the network config is invalid and there is a VEGA_URL in the environment', async () => {
|
|
||||||
const consoleWarnSpy = jest
|
|
||||||
.spyOn(console, 'warn')
|
|
||||||
.mockImplementation(noop);
|
|
||||||
|
|
||||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
|
||||||
global.fetch.mockImplementation(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ some: 'invalid-object' }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.configLoading).toBe(true);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
||||||
getErrorByType(
|
|
||||||
ErrorType.CONFIG_VALIDATION_ERROR,
|
|
||||||
mockEnvironmentState.VEGA_ENV
|
|
||||||
)?.headline
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has a network error when the selected node is not a valid url', async () => {
|
|
||||||
process.env['NX_VEGA_URL'] = 'not-url';
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
VEGA_URL: 'not-url',
|
|
||||||
nodeSwitcherOpen: true,
|
|
||||||
networkError: ErrorType.INVALID_URL,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has a network error when cannot connect to the selected node', async () => {
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation(() => {
|
|
||||||
return createMockClient({ statistics: { hasError: true } });
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
nodeSwitcherOpen: true,
|
|
||||||
networkError: ErrorType.CONNECTION_ERROR,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has a network error when the selected node has no subscription available', async () => {
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation(() => {
|
|
||||||
return createMockClient({ busEvents: { hasError: true } });
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useEnvironment(), {
|
|
||||||
wrapper: MockWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
...mockEnvironmentState,
|
|
||||||
networkError: ErrorType.SUBSCRIPTION_ERROR,
|
|
||||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
|
||||||
nodeSwitcherOpen: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
315
libs/environment/src/hooks/use-environment.ts
Normal file
315
libs/environment/src/hooks/use-environment.ts
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
import { isValidUrl, LocalStorage, t } from '@vegaprotocol/react-helpers';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { createClient } from '@vegaprotocol/apollo-client';
|
||||||
|
import type {
|
||||||
|
BlockTimeSubscription,
|
||||||
|
StatisticsQuery,
|
||||||
|
} from '../utils/__generated__/Node';
|
||||||
|
import { BlockTimeDocument } from '../utils/__generated__/Node';
|
||||||
|
import { StatisticsDocument } from '../utils/__generated__/Node';
|
||||||
|
import type { Environment } from '../types';
|
||||||
|
import { Networks } from '../types';
|
||||||
|
import { compileErrors } from '../utils/compile-errors';
|
||||||
|
import { envSchema } from '../utils/validate-environment';
|
||||||
|
import { configSchema } from '../utils/validate-configuration';
|
||||||
|
|
||||||
|
type Client = ReturnType<typeof createClient>;
|
||||||
|
type ClientCollection = {
|
||||||
|
[node: string]: Client;
|
||||||
|
};
|
||||||
|
type EnvState = {
|
||||||
|
nodes: string[];
|
||||||
|
status: 'default' | 'pending' | 'success' | 'failed';
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
type Actions = {
|
||||||
|
setUrl: (url: string) => void;
|
||||||
|
initialize: () => Promise<void>;
|
||||||
|
};
|
||||||
|
export type Env = Environment & EnvState;
|
||||||
|
export type EnvStore = Env & Actions;
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'vega_url';
|
||||||
|
const SUBSCRIPTION_TIMEOUT = 3000;
|
||||||
|
|
||||||
|
export const useEnvironment = create<EnvStore>((set, get) => ({
|
||||||
|
...compileEnvVars(),
|
||||||
|
nodes: [],
|
||||||
|
status: 'default',
|
||||||
|
error: null,
|
||||||
|
setUrl: (url) => {
|
||||||
|
set({ VEGA_URL: url, status: 'success', error: null });
|
||||||
|
LocalStorage.setItem(STORAGE_KEY, url);
|
||||||
|
},
|
||||||
|
initialize: async () => {
|
||||||
|
set({ status: 'pending' });
|
||||||
|
|
||||||
|
// validate env vars
|
||||||
|
try {
|
||||||
|
const rawVars = compileEnvVars();
|
||||||
|
const safeVars = envSchema.parse(rawVars);
|
||||||
|
set({ ...safeVars });
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (err: any) {
|
||||||
|
const headline = t('Error processing the Vega environment');
|
||||||
|
set({
|
||||||
|
status: 'failed',
|
||||||
|
error: headline,
|
||||||
|
});
|
||||||
|
console.error(compileErrors(headline, err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = get();
|
||||||
|
const storedUrl = LocalStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
let nodes: string[] | undefined;
|
||||||
|
try {
|
||||||
|
nodes = await fetchConfig(state.VEGA_CONFIG_URL);
|
||||||
|
set({ nodes });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Could not fetch node config from ${state.VEGA_CONFIG_URL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node url found in localStorage, if its valid attempt to connect
|
||||||
|
if (storedUrl) {
|
||||||
|
if (isValidUrl(storedUrl)) {
|
||||||
|
set({ VEGA_URL: storedUrl, status: 'success' });
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
LocalStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VEGA_URL env var is set and is a valid url no need to proceed
|
||||||
|
if (state.VEGA_URL) {
|
||||||
|
set({ status: 'success' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No url found in env vars or localStorage, AND no nodes were found in
|
||||||
|
// the config fetched from VEGA_CONFIG_URL, app initialization has failed
|
||||||
|
if (!nodes || !nodes.length) {
|
||||||
|
set({
|
||||||
|
status: 'failed',
|
||||||
|
error: t(`Failed to fetch node config from ${state.VEGA_CONFIG_URL}`),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of node urls to client instances
|
||||||
|
const clients: ClientCollection = {};
|
||||||
|
nodes.forEach((url) => {
|
||||||
|
clients[url] = createClient({
|
||||||
|
url,
|
||||||
|
cacheConfig: undefined,
|
||||||
|
retry: false,
|
||||||
|
connectToDevTools: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find a suitable node to connect to by attempting a query and a
|
||||||
|
// subscription, first to fulfill both will be the resulting url.
|
||||||
|
const url = await findNode(clients);
|
||||||
|
|
||||||
|
if (url !== null) {
|
||||||
|
set({
|
||||||
|
status: 'success',
|
||||||
|
VEGA_URL: url,
|
||||||
|
});
|
||||||
|
LocalStorage.setItem(STORAGE_KEY, url);
|
||||||
|
}
|
||||||
|
// Every node failed either to make a query or retrieve data from
|
||||||
|
// a subscription
|
||||||
|
else {
|
||||||
|
set({
|
||||||
|
status: 'failed',
|
||||||
|
error: t('No node found'),
|
||||||
|
});
|
||||||
|
console.warn(t('No suitable vega node was found'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Vega app to dynamically select a node from the
|
||||||
|
* VEGA_CONFIG_URL
|
||||||
|
*
|
||||||
|
* This can be ommitted if you intend to only use a single node,
|
||||||
|
* in those cases be sure to set NX_VEGA_URL
|
||||||
|
*/
|
||||||
|
export const useInitializeEnv = () => {
|
||||||
|
const { initialize, status } = useEnvironment((store) => ({
|
||||||
|
status: store.status,
|
||||||
|
initialize: store.initialize,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'default') {
|
||||||
|
initialize();
|
||||||
|
}
|
||||||
|
}, [status, initialize]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and validate a vega node configuration
|
||||||
|
*/
|
||||||
|
const fetchConfig = async (url?: string) => {
|
||||||
|
if (!url) return [];
|
||||||
|
const res = await fetch(url);
|
||||||
|
const cfg = await res.json();
|
||||||
|
const result = configSchema.parse(cfg);
|
||||||
|
return result.hosts;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a suitable node by running a test query and test
|
||||||
|
* subscription, against a list of clients, first to resolve wins
|
||||||
|
*/
|
||||||
|
const findNode = (clients: ClientCollection): Promise<string | null> => {
|
||||||
|
const tests = Object.entries(clients).map((args) => testNode(...args));
|
||||||
|
return Promise.race(tests);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test a node for suitability for connection
|
||||||
|
*/
|
||||||
|
const testNode = async (
|
||||||
|
url: string,
|
||||||
|
client: Client
|
||||||
|
): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const results = await Promise.all([
|
||||||
|
testQuery(client),
|
||||||
|
testSubscription(client),
|
||||||
|
]);
|
||||||
|
if (results[0] && results[1]) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Tests failed for ${url}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a test query on a client
|
||||||
|
*/
|
||||||
|
const testQuery = async (client: Client) => {
|
||||||
|
try {
|
||||||
|
const result = await client.query<StatisticsQuery>({
|
||||||
|
query: StatisticsDocument,
|
||||||
|
});
|
||||||
|
if (!result || result.error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a test subscription on a client. A subscription
|
||||||
|
* that takes longer than SUBSCRIPTION_TIMEOUT ms to respond
|
||||||
|
* is deemed a failure
|
||||||
|
*/
|
||||||
|
const testSubscription = (client: Client) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sub = client
|
||||||
|
.subscribe<BlockTimeSubscription>({
|
||||||
|
query: BlockTimeDocument,
|
||||||
|
errorPolicy: 'all',
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
resolve(true);
|
||||||
|
sub.unsubscribe();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
resolve(false);
|
||||||
|
sub.unsubscribe();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(false);
|
||||||
|
sub.unsubscribe();
|
||||||
|
}, SUBSCRIPTION_TIMEOUT);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve env vars, parsing where needed some type casting is needed
|
||||||
|
* here to appease the environment store interface
|
||||||
|
*/
|
||||||
|
function compileEnvVars() {
|
||||||
|
const VEGA_ENV = process.env['NX_VEGA_ENV'] as Networks;
|
||||||
|
const env: Environment = {
|
||||||
|
VEGA_URL: process.env['NX_VEGA_URL'],
|
||||||
|
VEGA_ENV,
|
||||||
|
VEGA_CONFIG_URL: process.env['NX_VEGA_CONFIG_URL'] as string,
|
||||||
|
VEGA_NETWORKS: parseNetworks(process.env['NX_VEGA_NETWORKS']),
|
||||||
|
VEGA_WALLET_URL: process.env['NX_VEGA_WALLET_URL'] as string,
|
||||||
|
HOSTED_WALLET_URL: process.env['NX_HOSTED_WALLET_URL'],
|
||||||
|
ETHERSCAN_URL: getEtherscanUrl(VEGA_ENV, process.env['NX_ETHERSCAN_URL']),
|
||||||
|
ETHEREUM_PROVIDER_URL: getEthereumProviderUrl(
|
||||||
|
VEGA_ENV,
|
||||||
|
process.env['NX_ETHEREUM_PROVIDER_URL']
|
||||||
|
),
|
||||||
|
ETH_LOCAL_PROVIDER_URL: process.env['NX_ETH_LOCAL_PROVIDER_URL'],
|
||||||
|
ETH_WALLET_MNEMONIC: process.env['NX_ETH_WALLET_MNEMONIC'],
|
||||||
|
VEGA_DOCS_URL: process.env['NX_VEGA_DOCS_URL'],
|
||||||
|
VEGA_EXPLORER_URL: process.env['NX_VEGA_EXPLORER_URL'],
|
||||||
|
VEGA_TOKEN_URL: process.env['NX_VEGA_TOKEN_URL'],
|
||||||
|
GITHUB_FEEDBACK_URL: process.env['NX_GITHUB_FEEDBACK_URL'],
|
||||||
|
MAINTENANCE_PAGE: parseBoolean(process.env['NX_MAINTENANCE_PAGE']),
|
||||||
|
GIT_BRANCH: process.env['GIT_COMMIT_BRANCH'],
|
||||||
|
GIT_COMMIT_HASH: process.env['GIT_COMMIT_HASH'],
|
||||||
|
GIT_ORIGIN_URL: process.env['GIT_ORIGIN_URL'],
|
||||||
|
};
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNetworks(value?: string) {
|
||||||
|
if (value) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoolean(value?: string) {
|
||||||
|
return ['true', '1', 'yes'].includes(value?.toLowerCase() || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a fallback ethereum provider url for test purposes in some apps
|
||||||
|
*/
|
||||||
|
function getEthereumProviderUrl(
|
||||||
|
network: Networks | undefined,
|
||||||
|
envvar: string | undefined
|
||||||
|
) {
|
||||||
|
if (envvar) return envvar;
|
||||||
|
return network === Networks.MAINNET
|
||||||
|
? 'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'
|
||||||
|
: 'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8';
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Provide a fallback etherscan url for test purposes in some apps
|
||||||
|
*/
|
||||||
|
function getEtherscanUrl(
|
||||||
|
network: Networks | undefined,
|
||||||
|
envvar: string | undefined
|
||||||
|
) {
|
||||||
|
if (envvar) return envvar;
|
||||||
|
return network === Networks.MAINNET
|
||||||
|
? 'https://etherscan.io'
|
||||||
|
: 'https://sepolia.etherscan.io';
|
||||||
|
}
|
@ -1,181 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import {
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useCallback,
|
|
||||||
} from 'react';
|
|
||||||
import { MaintenancePage } from '@vegaprotocol/ui-toolkit';
|
|
||||||
import { NodeSwitcherDialog } from '../components/node-switcher-dialog';
|
|
||||||
import { useConfig } from './use-config';
|
|
||||||
import { useNodes } from './use-nodes';
|
|
||||||
import { compileEnvironment } from '../utils/compile-environment';
|
|
||||||
import { validateEnvironment } from '../utils/validate-environment';
|
|
||||||
import {
|
|
||||||
getErrorType,
|
|
||||||
getErrorByType,
|
|
||||||
getIsNodeLoading,
|
|
||||||
} from '../utils/validate-node';
|
|
||||||
import { ErrorType } from '../types';
|
|
||||||
import type {
|
|
||||||
Environment,
|
|
||||||
Networks,
|
|
||||||
RawEnvironment,
|
|
||||||
NodeData,
|
|
||||||
Configuration,
|
|
||||||
} from '../types';
|
|
||||||
import { useNodeHealth } from './use-node-health';
|
|
||||||
|
|
||||||
type EnvironmentProviderProps = {
|
|
||||||
config?: Configuration;
|
|
||||||
definitions?: Partial<RawEnvironment>;
|
|
||||||
children?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EnvironmentState = Environment & {
|
|
||||||
configLoading: boolean;
|
|
||||||
networkError?: ErrorType;
|
|
||||||
blockDifference: number;
|
|
||||||
nodeSwitcherOpen: boolean;
|
|
||||||
setNodeSwitcherOpen: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const EnvironmentContext = createContext({} as EnvironmentState);
|
|
||||||
|
|
||||||
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 = ({
|
|
||||||
config: defaultConfig,
|
|
||||||
definitions,
|
|
||||||
children,
|
|
||||||
}: EnvironmentProviderProps) => {
|
|
||||||
const [networkError, setNetworkError] = useState<undefined | ErrorType>();
|
|
||||||
const [isNodeSwitcherOpen, setNodeSwitcherIsOpen] = useState(false);
|
|
||||||
const [environment, updateEnvironment] = useState<Environment>(
|
|
||||||
compileEnvironment(definitions)
|
|
||||||
);
|
|
||||||
const setNodeSwitcherOpen = useCallback((isOpen: boolean) => {
|
|
||||||
if (!('Cypress' in window)) {
|
|
||||||
setNodeSwitcherIsOpen(isOpen);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
const { loading, config } = useConfig(
|
|
||||||
{ environment, defaultConfig },
|
|
||||||
(errorType) => {
|
|
||||||
if (!environment.VEGA_URL) {
|
|
||||||
setNetworkError(errorType);
|
|
||||||
setNodeSwitcherOpen(true);
|
|
||||||
} else {
|
|
||||||
const error = getErrorByType(errorType, environment.VEGA_ENV);
|
|
||||||
error && console.warn(error.headline);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { state: nodes, clients } = useNodes(
|
|
||||||
config,
|
|
||||||
environment.MAINTENANCE_PAGE
|
|
||||||
);
|
|
||||||
|
|
||||||
const blockDifference = useNodeHealth(clients, environment.VEGA_URL);
|
|
||||||
|
|
||||||
const nodeKeys = Object.keys(nodes);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!environment.VEGA_URL) {
|
|
||||||
const successfulNodeKey = nodeKeys.find((key) =>
|
|
||||||
hasLoaded(environment.VEGA_ENV, nodes[key])
|
|
||||||
);
|
|
||||||
if (successfulNodeKey && nodes[successfulNodeKey]) {
|
|
||||||
Object.keys(clients).forEach((node) => clients[node]?.stop());
|
|
||||||
const url = nodes[successfulNodeKey].url;
|
|
||||||
updateEnvironment((prevEnvironment) => ({
|
|
||||||
...prevEnvironment,
|
|
||||||
VEGA_URL: url,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the selected node has errors
|
|
||||||
if (environment.VEGA_URL && nodes[environment.VEGA_URL]) {
|
|
||||||
const errorType = getErrorType(
|
|
||||||
environment.VEGA_ENV,
|
|
||||||
nodes[environment.VEGA_URL]
|
|
||||||
);
|
|
||||||
if (errorType !== null) {
|
|
||||||
Object.keys(clients).forEach((node) => clients[node]?.stop());
|
|
||||||
setNetworkError(errorType);
|
|
||||||
setNodeSwitcherOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the config doesn't contain nodes the app can connect to
|
|
||||||
if (
|
|
||||||
nodeKeys.length > 0 &&
|
|
||||||
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);
|
|
||||||
setNodeSwitcherOpen(true);
|
|
||||||
}
|
|
||||||
// prevent infinite render loop by skipping deps which will change as a result
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [environment.VEGA_URL, nodes]);
|
|
||||||
|
|
||||||
const errorMessage = validateEnvironment(environment);
|
|
||||||
|
|
||||||
if (errorMessage) {
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (environment.MAINTENANCE_PAGE) {
|
|
||||||
return <MaintenancePage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EnvironmentContext.Provider
|
|
||||||
value={{
|
|
||||||
...environment,
|
|
||||||
configLoading: loading,
|
|
||||||
networkError,
|
|
||||||
blockDifference,
|
|
||||||
nodeSwitcherOpen: isNodeSwitcherOpen,
|
|
||||||
setNodeSwitcherOpen: () => setNodeSwitcherOpen(true),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<NodeSwitcherDialog
|
|
||||||
dialogOpen={isNodeSwitcherOpen}
|
|
||||||
initialErrorType={networkError}
|
|
||||||
setDialogOpen={setNodeSwitcherOpen}
|
|
||||||
loading={loading}
|
|
||||||
config={config}
|
|
||||||
onConnect={(url) => {
|
|
||||||
updateEnvironment((env) => ({ ...env, VEGA_URL: url }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
</EnvironmentContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useEnvironment = () => {
|
|
||||||
const context = useContext(EnvironmentContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error(
|
|
||||||
'Error running "useEnvironment". No context found, make sure your component is wrapped in an <EnvironmentProvider />.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
@ -1,117 +1,114 @@
|
|||||||
import { act, renderHook } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import {
|
import { useNodeHealth } from './use-node-health';
|
||||||
useNodeHealth,
|
import type { MockedResponse } from '@apollo/react-testing';
|
||||||
NODE_SUBSET_COUNT,
|
import { MockedProvider } from '@apollo/react-testing';
|
||||||
INTERVAL_TIME,
|
import type { StatisticsQuery } from '../utils/__generated__/Node';
|
||||||
} from './use-node-health';
|
import { StatisticsDocument } from '../utils/__generated__/Node';
|
||||||
import type { createClient } from '@vegaprotocol/apollo-client';
|
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||||
import type { ClientCollection } from './use-nodes';
|
|
||||||
|
|
||||||
function setup(...args: Parameters<typeof useNodeHealth>) {
|
const vegaUrl = 'https://foo.bar.com';
|
||||||
return renderHook(() => useNodeHealth(...args));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockClient(blockHeight: number) {
|
jest.mock('./use-environment', () => ({
|
||||||
|
useEnvironment: () => vegaUrl,
|
||||||
|
}));
|
||||||
|
jest.mock('@vegaprotocol/apollo-client');
|
||||||
|
|
||||||
|
const createStatsMock = (
|
||||||
|
blockHeight: number
|
||||||
|
): MockedResponse<StatisticsQuery> => {
|
||||||
return {
|
return {
|
||||||
query: jest.fn().mockResolvedValue({
|
request: {
|
||||||
|
query: StatisticsDocument,
|
||||||
|
},
|
||||||
|
result: {
|
||||||
data: {
|
data: {
|
||||||
statistics: {
|
statistics: {
|
||||||
chainId: 'chain-id',
|
chainId: 'chain-id',
|
||||||
blockHeight: blockHeight.toString(),
|
blockHeight: blockHeight.toString(),
|
||||||
|
vegaTime: '12345',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
} as unknown as ReturnType<typeof createClient>;
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
function createRejectingClient() {
|
function setup(
|
||||||
return {
|
mock: MockedResponse<StatisticsQuery>,
|
||||||
query: () => Promise.reject(new Error('request failed')),
|
headers:
|
||||||
} as unknown as ReturnType<typeof createClient>;
|
| {
|
||||||
}
|
blockHeight: number;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
) {
|
||||||
|
// @ts-ignore ignore mock implementation
|
||||||
|
useHeaderStore.mockImplementation(() => ({
|
||||||
|
[vegaUrl]: headers,
|
||||||
|
}));
|
||||||
|
|
||||||
function createErroringClient() {
|
return renderHook(() => useNodeHealth(), {
|
||||||
return {
|
wrapper: ({ children }) => (
|
||||||
query: () =>
|
<MockedProvider mocks={[mock]}>{children}</MockedProvider>
|
||||||
Promise.resolve({
|
),
|
||||||
error: new Error('failed'),
|
});
|
||||||
}),
|
|
||||||
} as unknown as ReturnType<typeof createClient>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CURRENT_URL = 'https://current.test.com';
|
|
||||||
|
|
||||||
describe('useNodeHealth', () => {
|
describe('useNodeHealth', () => {
|
||||||
beforeAll(() => {
|
it.each([
|
||||||
jest.useFakeTimers();
|
{ core: 1, node: 1, expected: 0 },
|
||||||
});
|
{ core: 1, node: 5, expected: -4 },
|
||||||
|
{ core: 10, node: 5, expected: 5 },
|
||||||
|
])(
|
||||||
|
'provides difference core block $core and node block $node',
|
||||||
|
async (cases) => {
|
||||||
|
const { result } = setup(createStatsMock(cases.core), {
|
||||||
|
blockHeight: cases.node,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
expect(result.current.blockDiff).toEqual(null);
|
||||||
|
expect(result.current.coreBlockHeight).toEqual(undefined);
|
||||||
|
expect(result.current.datanodeBlockHeight).toEqual(cases.node);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.blockDiff).toEqual(cases.expected);
|
||||||
|
expect(result.current.coreBlockHeight).toEqual(cases.core);
|
||||||
|
expect(result.current.datanodeBlockHeight).toEqual(cases.node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('provides difference between the highest block and the current block', async () => {
|
it('block diff is null if query fails indicating non operational', async () => {
|
||||||
const highest = 100;
|
const failedQuery: MockedResponse<StatisticsQuery> = {
|
||||||
const curr = 97;
|
request: {
|
||||||
const clientCollection: ClientCollection = {
|
query: StatisticsDocument,
|
||||||
[CURRENT_URL]: createMockClient(curr),
|
},
|
||||||
'https://n02.test.com': createMockClient(98),
|
result: {
|
||||||
'https://n03.test.com': createMockClient(highest),
|
// @ts-ignore failed query with no result
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const { result } = setup(clientCollection, CURRENT_URL);
|
const { result } = setup(failedQuery, {
|
||||||
await act(async () => {
|
blockHeight: 1,
|
||||||
jest.advanceTimersByTime(INTERVAL_TIME);
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
expect(result.current.blockDiff).toEqual(null);
|
||||||
|
expect(result.current.coreBlockHeight).toEqual(undefined);
|
||||||
|
expect(result.current.datanodeBlockHeight).toEqual(1);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.blockDiff).toEqual(null);
|
||||||
|
expect(result.current.coreBlockHeight).toEqual(undefined);
|
||||||
|
expect(result.current.datanodeBlockHeight).toEqual(1);
|
||||||
});
|
});
|
||||||
expect(result.current).toBe(highest - curr);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns -1 if the current node query fails', async () => {
|
it('returns 0 if no headers are found (waits until stats query resolves)', async () => {
|
||||||
const clientCollection: ClientCollection = {
|
const { result } = setup(createStatsMock(1), undefined);
|
||||||
[CURRENT_URL]: createRejectingClient(),
|
expect(result.current.blockDiff).toEqual(null);
|
||||||
'https://n02.test.com': createMockClient(200),
|
expect(result.current.coreBlockHeight).toEqual(undefined);
|
||||||
'https://n03.test.com': createMockClient(102),
|
expect(result.current.datanodeBlockHeight).toEqual(undefined);
|
||||||
};
|
await waitFor(() => {
|
||||||
const { result } = setup(clientCollection, CURRENT_URL);
|
expect(result.current.blockDiff).toEqual(0);
|
||||||
await act(async () => {
|
expect(result.current.coreBlockHeight).toEqual(1);
|
||||||
jest.advanceTimersByTime(INTERVAL_TIME);
|
expect(result.current.datanodeBlockHeight).toEqual(undefined);
|
||||||
});
|
});
|
||||||
expect(result.current).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns -1 if the current node query returns an error', async () => {
|
|
||||||
const clientCollection: ClientCollection = {
|
|
||||||
[CURRENT_URL]: createErroringClient(),
|
|
||||||
'https://n02.test.com': createMockClient(200),
|
|
||||||
'https://n03.test.com': createMockClient(102),
|
|
||||||
};
|
|
||||||
const { result } = setup(clientCollection, CURRENT_URL);
|
|
||||||
await act(async () => {
|
|
||||||
jest.advanceTimersByTime(INTERVAL_TIME);
|
|
||||||
});
|
|
||||||
expect(result.current).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('queries against 5 random nodes along with the current url', async () => {
|
|
||||||
const clientCollection: ClientCollection = new Array(20)
|
|
||||||
.fill(null)
|
|
||||||
.reduce((obj, x, i) => {
|
|
||||||
obj[`https://n${i}.test.com`] = createMockClient(100);
|
|
||||||
return obj;
|
|
||||||
}, {} as ClientCollection);
|
|
||||||
clientCollection[CURRENT_URL] = createMockClient(100);
|
|
||||||
const spyOnCurrent = jest.spyOn(clientCollection[CURRENT_URL], 'query');
|
|
||||||
|
|
||||||
const { result } = setup(clientCollection, CURRENT_URL);
|
|
||||||
await act(async () => {
|
|
||||||
jest.advanceTimersByTime(INTERVAL_TIME);
|
|
||||||
});
|
|
||||||
|
|
||||||
let count = 0;
|
|
||||||
Object.values(clientCollection).forEach((client) => {
|
|
||||||
// @ts-ignore jest.fn() in client setup means mock will be present
|
|
||||||
if (client?.query.mock.calls.length) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(count).toBe(NODE_SUBSET_COUNT + 1);
|
|
||||||
expect(spyOnCurrent).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result.current).toBe(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,88 +1,47 @@
|
|||||||
import compact from 'lodash/compact';
|
import { useEffect, useMemo } from 'react';
|
||||||
import shuffle from 'lodash/shuffle';
|
import { useStatisticsQuery } from '../utils/__generated__/Node';
|
||||||
import type { createClient } from '@vegaprotocol/apollo-client';
|
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEnvironment } from './use-environment';
|
||||||
import type { StatisticsQuery } from '../utils/__generated__/Node';
|
import { fromNanoSeconds } from '@vegaprotocol/react-helpers';
|
||||||
import { StatisticsDocument } from '../utils/__generated__/Node';
|
|
||||||
import type { ClientCollection } from './use-nodes';
|
|
||||||
|
|
||||||
// How often to query other nodes
|
export const useNodeHealth = () => {
|
||||||
export const INTERVAL_TIME = 30 * 1000;
|
const url = useEnvironment((store) => store.VEGA_URL);
|
||||||
// Number of nodes to query against
|
const headerStore = useHeaderStore();
|
||||||
export const NODE_SUBSET_COUNT = 5;
|
const headers = url ? headerStore[url] : undefined;
|
||||||
|
const { data, error, loading, stopPolling } = useStatisticsQuery({
|
||||||
|
pollInterval: 1000,
|
||||||
|
fetchPolicy: 'no-cache',
|
||||||
|
});
|
||||||
|
|
||||||
// Queries all nodes from the environment provider via an interval
|
const blockDiff = useMemo(() => {
|
||||||
// to calculate and return the difference between the most advanced block
|
if (!data?.statistics.blockHeight) {
|
||||||
// and the block height of the current node
|
return null;
|
||||||
export const useNodeHealth = (clients: ClientCollection, vegaUrl?: string) => {
|
}
|
||||||
const [blockDiff, setBlockDiff] = useState(0);
|
|
||||||
|
if (!headers) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(data.statistics.blockHeight) - headers.blockHeight;
|
||||||
|
}, [data, headers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clients || !vegaUrl) return;
|
if (error) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}, [error, stopPolling]);
|
||||||
|
|
||||||
const fetchBlockHeight = async (
|
return {
|
||||||
client?: ReturnType<typeof createClient>
|
error,
|
||||||
) => {
|
loading,
|
||||||
try {
|
coreBlockHeight: data?.statistics
|
||||||
const result = await client?.query<StatisticsQuery>({
|
? Number(data.statistics.blockHeight)
|
||||||
query: StatisticsDocument,
|
: undefined,
|
||||||
fetchPolicy: 'no-cache', // always fetch and never cache
|
coreVegaTime: data?.statistics
|
||||||
});
|
? fromNanoSeconds(data?.statistics.vegaTime)
|
||||||
|
: undefined,
|
||||||
if (!result) return null;
|
datanodeBlockHeight: headers?.blockHeight,
|
||||||
if (result.error) return null;
|
datanodeVegaTime: headers?.timestamp,
|
||||||
return result;
|
blockDiff,
|
||||||
} catch {
|
};
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBlockHeights = async () => {
|
|
||||||
const nodes = Object.keys(clients).filter((key) => key !== vegaUrl);
|
|
||||||
// make sure that your current vega url is always included
|
|
||||||
// so we can compare later
|
|
||||||
const testNodes = [vegaUrl, ...randomSubset(nodes, NODE_SUBSET_COUNT)];
|
|
||||||
const result = await Promise.all(
|
|
||||||
testNodes.map((node) => fetchBlockHeight(clients[node]))
|
|
||||||
);
|
|
||||||
const blockHeights: { [node: string]: number | null } = {};
|
|
||||||
testNodes.forEach((node, i) => {
|
|
||||||
const data = result[i];
|
|
||||||
const blockHeight = data
|
|
||||||
? Number(data?.data.statistics.blockHeight)
|
|
||||||
: null;
|
|
||||||
blockHeights[node] = blockHeight;
|
|
||||||
});
|
|
||||||
return blockHeights;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Every INTERVAL_TIME get block heights of a random subset
|
|
||||||
// of nodes and determine if your current node is falling behind
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
const blockHeights = await getBlockHeights();
|
|
||||||
const highestBlock = Math.max.apply(
|
|
||||||
null,
|
|
||||||
compact(Object.values(blockHeights))
|
|
||||||
);
|
|
||||||
const currNodeBlock = blockHeights[vegaUrl];
|
|
||||||
|
|
||||||
if (!currNodeBlock) {
|
|
||||||
// Block height query failed and null was returned
|
|
||||||
setBlockDiff(-1);
|
|
||||||
} else {
|
|
||||||
setBlockDiff(highestBlock - currNodeBlock);
|
|
||||||
}
|
|
||||||
}, INTERVAL_TIME);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [clients, vegaUrl]);
|
|
||||||
|
|
||||||
return blockDiff;
|
|
||||||
};
|
|
||||||
|
|
||||||
const randomSubset = (arr: string[], size: number) => {
|
|
||||||
const shuffled = shuffle(arr);
|
|
||||||
return shuffled.slice(0, size);
|
|
||||||
};
|
};
|
||||||
|
@ -1,366 +0,0 @@
|
|||||||
import { renderHook, act } from '@testing-library/react';
|
|
||||||
import { ApolloClient } from '@apollo/client';
|
|
||||||
import { createClient } from '@vegaprotocol/apollo-client';
|
|
||||||
import { useNodes } from './use-nodes';
|
|
||||||
import createMockClient, {
|
|
||||||
getMockStatisticsResult,
|
|
||||||
} from './mocks/apollo-client';
|
|
||||||
import { waitFor } from '@testing-library/react';
|
|
||||||
|
|
||||||
jest.mock('@vegaprotocol/apollo-client');
|
|
||||||
|
|
||||||
const MOCK_DURATION = 1073;
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
url: '',
|
|
||||||
initialized: false,
|
|
||||||
responseTime: {
|
|
||||||
isLoading: false,
|
|
||||||
hasError: false,
|
|
||||||
value: undefined,
|
|
||||||
},
|
|
||||||
block: {
|
|
||||||
isLoading: false,
|
|
||||||
hasError: false,
|
|
||||||
value: undefined,
|
|
||||||
},
|
|
||||||
subscription: {
|
|
||||||
isLoading: false,
|
|
||||||
hasError: false,
|
|
||||||
value: undefined,
|
|
||||||
},
|
|
||||||
chain: {
|
|
||||||
isLoading: false,
|
|
||||||
hasError: false,
|
|
||||||
value: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
window.performance.getEntriesByName = jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation((url: string) => [
|
|
||||||
{
|
|
||||||
entryType: 'resource',
|
|
||||||
name: url,
|
|
||||||
startTime: 0,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
duration: MOCK_DURATION,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation(() => createMockClient());
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useNodes hook', () => {
|
|
||||||
it('returns the default state when empty config provided', () => {
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
|
||||||
|
|
||||||
expect(result.current.state).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets loading state while waiting for the results', async () => {
|
|
||||||
const node = 'https://some.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
|
||||||
|
|
||||||
expect(result.current.state[node]).toEqual({
|
|
||||||
...initialState,
|
|
||||||
url: node,
|
|
||||||
initialized: true,
|
|
||||||
responseTime: {
|
|
||||||
...initialState.responseTime,
|
|
||||||
isLoading: true,
|
|
||||||
},
|
|
||||||
block: {
|
|
||||||
...initialState.block,
|
|
||||||
isLoading: true,
|
|
||||||
},
|
|
||||||
chain: {
|
|
||||||
...initialState.chain,
|
|
||||||
isLoading: true,
|
|
||||||
},
|
|
||||||
subscription: {
|
|
||||||
...initialState.subscription,
|
|
||||||
isLoading: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets statistics results', async () => {
|
|
||||||
const mockResult = getMockStatisticsResult();
|
|
||||||
const node = 'https://some.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].block).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: false,
|
|
||||||
value: Number(mockResult.statistics.blockHeight),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.state[node].chain).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: false,
|
|
||||||
value: mockResult.statistics.chainId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.state[node].responseTime).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: false,
|
|
||||||
value: MOCK_DURATION,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets subscription result', async () => {
|
|
||||||
const node = 'https://some.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].subscription).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: false,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets error when host in not a valid url', async () => {
|
|
||||||
const node = 'not-url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].block.hasError).toBe(true);
|
|
||||||
expect(result.current.state[node].chain.hasError).toBe(true);
|
|
||||||
expect(result.current.state[node].responseTime.hasError).toBe(true);
|
|
||||||
expect(result.current.state[node].responseTime.hasError).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets error when statistics request fails', async () => {
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation(() =>
|
|
||||||
createMockClient({ statistics: { hasError: true } })
|
|
||||||
);
|
|
||||||
|
|
||||||
const node = 'https://some.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].block).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: true,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.state[node].chain).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: true,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.state[node].responseTime).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: true,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets error when subscription request fails', async () => {
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation(() =>
|
|
||||||
createMockClient({ busEvents: { hasError: true } })
|
|
||||||
);
|
|
||||||
|
|
||||||
const node = 'https://some.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].subscription).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: true,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows updating block values', async () => {
|
|
||||||
const mockResult = getMockStatisticsResult();
|
|
||||||
const node = 'https://some.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].block.value).toEqual(
|
|
||||||
Number(mockResult.statistics.blockHeight)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.updateNodeBlock(node, 12);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].block.value).toEqual(12);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing when calling the block update on a non-existing node', async () => {
|
|
||||||
const mockResult = getMockStatisticsResult();
|
|
||||||
const node = 'https://some.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].block.value).toEqual(
|
|
||||||
Number(mockResult.statistics.blockHeight)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.updateNodeBlock('https://non-existing.url', 12);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.state['https://non-existing.url']).toBe(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds new node', async () => {
|
|
||||||
const node = 'custom-node-key';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
|
||||||
|
|
||||||
expect(result.current.state[node]).toEqual(undefined);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.addNode(node);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node]).toEqual(initialState);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets new url for node', async () => {
|
|
||||||
const node = 'https://some.url';
|
|
||||||
const newUrl = 'https://some-other.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.updateNodeUrl(node, newUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].url).toBe(newUrl);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets error when custom node has an invalid url', async () => {
|
|
||||||
const node = 'node-key';
|
|
||||||
const url = 'not-url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
|
||||||
|
|
||||||
expect(result.current.state[node]).toBe(undefined);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.updateNodeUrl(node, url);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].url).toBe(url);
|
|
||||||
expect(result.current.state[node].block.hasError).toBe(true);
|
|
||||||
expect(result.current.state[node].chain.hasError).toBe(true);
|
|
||||||
expect(result.current.state[node].responseTime.hasError).toBe(true);
|
|
||||||
expect(result.current.state[node].subscription.hasError).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets error when custom node statistics request fails', async () => {
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation(() =>
|
|
||||||
createMockClient({ statistics: { hasError: true } })
|
|
||||||
);
|
|
||||||
|
|
||||||
const node = 'node-key';
|
|
||||||
const url = 'https://some.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
|
||||||
|
|
||||||
expect(result.current.state[node]).toBe(undefined);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.updateNodeUrl(node, url);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].block).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: true,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.state[node].chain).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: true,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.state[node].responseTime).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: true,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets error when custom node subscription fails', async () => {
|
|
||||||
// @ts-ignore allow adding a mock return value to mocked module
|
|
||||||
createClient.mockImplementation(() =>
|
|
||||||
createMockClient({ busEvents: { hasError: true } })
|
|
||||||
);
|
|
||||||
|
|
||||||
const node = 'node-key';
|
|
||||||
const url = 'https://some.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
|
||||||
|
|
||||||
expect(result.current.state[node]).toBe(undefined);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.updateNodeUrl(node, url);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.state[node].subscription).toEqual({
|
|
||||||
isLoading: false,
|
|
||||||
hasError: true,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes a collection of clients', async () => {
|
|
||||||
const url1 = 'https://some.url';
|
|
||||||
const url2 = 'https://some-other.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [url1, url2] }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.clients[url1]).toBeInstanceOf(ApolloClient);
|
|
||||||
expect(result.current.clients[url2]).toBeInstanceOf(ApolloClient);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes a client for the custom node', async () => {
|
|
||||||
const node = 'node-key';
|
|
||||||
const url = 'https://some.url';
|
|
||||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.updateNodeUrl(node, url);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.clients[url]).toBeInstanceOf(ApolloClient);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,253 +0,0 @@
|
|||||||
import type { Dispatch } from 'react';
|
|
||||||
import { useState, useEffect, useReducer } from 'react';
|
|
||||||
import { produce } from 'immer';
|
|
||||||
import { initializeNode } from '../utils/initialize-node';
|
|
||||||
import type { NodeData, Configuration } from '../types';
|
|
||||||
import type { createClient } from '@vegaprotocol/apollo-client';
|
|
||||||
|
|
||||||
type StatisticsPayload = {
|
|
||||||
block: NodeData['block']['value'];
|
|
||||||
chain: NodeData['chain']['value'];
|
|
||||||
responseTime: NodeData['responseTime']['value'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export enum ACTIONS {
|
|
||||||
GET_STATISTICS,
|
|
||||||
GET_STATISTICS_SUCCESS,
|
|
||||||
GET_STATISTICS_FAILURE,
|
|
||||||
CHECK_SUBSCRIPTION,
|
|
||||||
CHECK_SUBSCRIPTION_SUCCESS,
|
|
||||||
CHECK_SUBSCRIPTION_FAILURE,
|
|
||||||
ADD_NODE,
|
|
||||||
UPDATE_NODE_URL,
|
|
||||||
UPDATE_NODE_BLOCK,
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionType<T extends ACTIONS, P = undefined> = {
|
|
||||||
type: T;
|
|
||||||
node: string;
|
|
||||||
payload?: P;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Action =
|
|
||||||
| ActionType<ACTIONS.GET_STATISTICS, { url: string }>
|
|
||||||
| ActionType<ACTIONS.GET_STATISTICS_SUCCESS, StatisticsPayload>
|
|
||||||
| ActionType<ACTIONS.GET_STATISTICS_FAILURE>
|
|
||||||
| ActionType<ACTIONS.CHECK_SUBSCRIPTION, { url: string }>
|
|
||||||
| ActionType<ACTIONS.CHECK_SUBSCRIPTION_SUCCESS>
|
|
||||||
| ActionType<ACTIONS.CHECK_SUBSCRIPTION_FAILURE>
|
|
||||||
| ActionType<ACTIONS.ADD_NODE>
|
|
||||||
| ActionType<ACTIONS.UPDATE_NODE_URL, { url: string }>
|
|
||||||
| ActionType<ACTIONS.UPDATE_NODE_BLOCK, number>;
|
|
||||||
|
|
||||||
function withData<T>(value?: T) {
|
|
||||||
return {
|
|
||||||
isLoading: false,
|
|
||||||
hasError: false,
|
|
||||||
value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function withError<T>(value?: T) {
|
|
||||||
return {
|
|
||||||
isLoading: false,
|
|
||||||
hasError: true,
|
|
||||||
value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const getNodeData = (url?: string): NodeData => ({
|
|
||||||
url: url ?? '',
|
|
||||||
initialized: false,
|
|
||||||
responseTime: withData(),
|
|
||||||
block: withData(),
|
|
||||||
subscription: withData(),
|
|
||||||
chain: withData(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const getInitialState = (config?: Configuration) =>
|
|
||||||
(config?.hosts ?? []).reduce<Record<string, NodeData>>(
|
|
||||||
(acc, url) => ({
|
|
||||||
...acc,
|
|
||||||
[url]: getNodeData(url),
|
|
||||||
}),
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ClientCollection = Record<
|
|
||||||
string,
|
|
||||||
undefined | ReturnType<typeof createClient>
|
|
||||||
>;
|
|
||||||
|
|
||||||
type ClientData = {
|
|
||||||
clients: ClientCollection;
|
|
||||||
subscriptions: ReturnType<typeof initializeNode>['unsubscribe'][];
|
|
||||||
};
|
|
||||||
|
|
||||||
const initializeNodes = (
|
|
||||||
dispatch: Dispatch<Action>,
|
|
||||||
nodes: Record<string, string>
|
|
||||||
) => {
|
|
||||||
return Object.keys(nodes).reduce<ClientData>(
|
|
||||||
(acc, node) => {
|
|
||||||
const { client, unsubscribe } = initializeNode(
|
|
||||||
dispatch,
|
|
||||||
node,
|
|
||||||
nodes[node]
|
|
||||||
);
|
|
||||||
Object.assign(acc.clients, { [nodes[node]]: client });
|
|
||||||
acc.subscriptions.push(unsubscribe);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
clients: {},
|
|
||||||
subscriptions: [],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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].subscription.isLoading = true;
|
|
||||||
state[action.node].initialized = true;
|
|
||||||
});
|
|
||||||
case ACTIONS.CHECK_SUBSCRIPTION_SUCCESS:
|
|
||||||
return produce(state, (state) => {
|
|
||||||
if (!state[action.node]) return;
|
|
||||||
state[action.node].subscription = withData(true);
|
|
||||||
});
|
|
||||||
case ACTIONS.CHECK_SUBSCRIPTION_FAILURE:
|
|
||||||
return produce(state, (state) => {
|
|
||||||
if (!state[action.node]) return;
|
|
||||||
state[action.node].subscription = 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests each node to see if its suitable for connecting to and returns that data
|
|
||||||
* as a map of node urls to an object of that data
|
|
||||||
*/
|
|
||||||
export const useNodes = (config?: Configuration, skip?: boolean) => {
|
|
||||||
const [clients, setClients] = useState<ClientCollection>({});
|
|
||||||
const [state, dispatch] = useReducer(reducer, getInitialState(config));
|
|
||||||
const configCacheKey = config?.hosts.join(';');
|
|
||||||
const allUrls = Object.keys(state).map((node) => state[node].url);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
Object.keys(clients).forEach((url) => clients[url]?.stop());
|
|
||||||
};
|
|
||||||
// stop all created clients on unmount
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hosts = skip ? [] : config?.hosts || [];
|
|
||||||
const nodeUrlMap = hosts.reduce((acc, url) => ({ ...acc, [url]: url }), {});
|
|
||||||
const { clients: newClients, subscriptions } = initializeNodes(
|
|
||||||
dispatch,
|
|
||||||
nodeUrlMap
|
|
||||||
);
|
|
||||||
setClients(newClients);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
||||||
};
|
|
||||||
// use primitive cache key to prevent infinite rerender loop
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [configCacheKey, skip]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const allNodes = Object.keys(state);
|
|
||||||
const initializedUrls = Object.keys(clients);
|
|
||||||
const nodeUrlMap = allUrls
|
|
||||||
.filter((node) => !initializedUrls.includes(node))
|
|
||||||
.reduce<Record<string, string>>((acc, url) => {
|
|
||||||
const node = allNodes.find((key) => state[key].url === url);
|
|
||||||
if (node) {
|
|
||||||
acc[node] = url;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const { clients: newClients, subscriptions } = initializeNodes(
|
|
||||||
dispatch,
|
|
||||||
nodeUrlMap
|
|
||||||
);
|
|
||||||
setClients((prevClients) => ({
|
|
||||||
...prevClients,
|
|
||||||
...newClients,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
||||||
};
|
|
||||||
// use primitive cache key to prevent infinite rerender loop
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [allUrls.join(';')]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
clients,
|
|
||||||
addNode: (node: string) => dispatch({ type: ACTIONS.ADD_NODE, node }),
|
|
||||||
updateNodeUrl: (node: string, url: string) =>
|
|
||||||
dispatch({ type: ACTIONS.UPDATE_NODE_URL, node, payload: { url } }),
|
|
||||||
updateNodeBlock: (node: string, value: number) =>
|
|
||||||
dispatch({ type: ACTIONS.UPDATE_NODE_BLOCK, node, payload: value }),
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,44 +1,17 @@
|
|||||||
import type z from 'zod';
|
import type z from 'zod';
|
||||||
|
|
||||||
import type { configSchema } from './utils/validate-configuration';
|
import type { configSchema } from './utils/validate-configuration';
|
||||||
import type { envSchema } from './utils/validate-environment';
|
import type { envSchema } from './utils/validate-environment';
|
||||||
import { Networks, ENV_KEYS } from './utils/validate-environment';
|
|
||||||
|
|
||||||
export { ENV_KEYS, Networks };
|
export enum Networks {
|
||||||
|
CUSTOM = 'CUSTOM',
|
||||||
export const CUSTOM_NODE_KEY = 'custom';
|
SANDBOX = 'SANDBOX',
|
||||||
|
TESTNET = 'TESTNET',
|
||||||
export enum ErrorType {
|
STAGNET1 = 'STAGNET1',
|
||||||
INVALID_URL,
|
STAGNET3 = 'STAGNET3',
|
||||||
SUBSCRIPTION_ERROR,
|
DEVNET = 'DEVNET',
|
||||||
CONNECTION_ERROR,
|
MAINNET = 'MAINNET',
|
||||||
CONNECTION_ERROR_ALL,
|
MIRROR = 'MIRROR',
|
||||||
CONFIG_LOAD_ERROR,
|
|
||||||
CONFIG_VALIDATION_ERROR,
|
|
||||||
}
|
}
|
||||||
|
export type Environment = z.infer<typeof envSchema>;
|
||||||
export type Environment = z.infer<typeof envSchema> & {
|
|
||||||
// provide this manually, zod fails to compile the correct type fot VEGA_NETWORKS
|
|
||||||
VEGA_NETWORKS: Partial<Record<Networks, string>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EnvKey = keyof Environment;
|
|
||||||
|
|
||||||
export type RawEnvironment = Record<EnvKey, string>;
|
|
||||||
|
|
||||||
export type Configuration = z.infer<typeof configSchema>;
|
export type Configuration = z.infer<typeof configSchema>;
|
||||||
|
export const CUSTOM_NODE_KEY = 'custom' as const;
|
||||||
type NodeCheck<T> = {
|
|
||||||
isLoading: boolean;
|
|
||||||
hasError: boolean;
|
|
||||||
value?: T;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NodeData = {
|
|
||||||
url: string;
|
|
||||||
initialized: boolean;
|
|
||||||
subscription: NodeCheck<boolean>;
|
|
||||||
block: NodeCheck<number>;
|
|
||||||
responseTime: NodeCheck<number>;
|
|
||||||
chain: NodeCheck<string>;
|
|
||||||
};
|
|
||||||
|
@ -2,6 +2,7 @@ query Statistics {
|
|||||||
statistics {
|
statistics {
|
||||||
chainId
|
chainId
|
||||||
blockHeight
|
blockHeight
|
||||||
|
vegaTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
3
libs/environment/src/utils/__generated__/Node.ts
generated
3
libs/environment/src/utils/__generated__/Node.ts
generated
@ -6,7 +6,7 @@ const defaultOptions = {} as const;
|
|||||||
export type StatisticsQueryVariables = Types.Exact<{ [key: string]: never; }>;
|
export type StatisticsQueryVariables = Types.Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
export type StatisticsQuery = { __typename?: 'Query', statistics: { __typename?: 'Statistics', chainId: string, blockHeight: string } };
|
export type StatisticsQuery = { __typename?: 'Query', statistics: { __typename?: 'Statistics', chainId: string, blockHeight: string, vegaTime: any } };
|
||||||
|
|
||||||
export type BlockTimeSubscriptionVariables = Types.Exact<{ [key: string]: never; }>;
|
export type BlockTimeSubscriptionVariables = Types.Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
@ -19,6 +19,7 @@ export const StatisticsDocument = gql`
|
|||||||
statistics {
|
statistics {
|
||||||
chainId
|
chainId
|
||||||
blockHeight
|
blockHeight
|
||||||
|
vegaTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -1,140 +0,0 @@
|
|||||||
import type { RawEnvironment, EnvKey, Environment } from '../types';
|
|
||||||
import { Networks, ENV_KEYS } from '../types';
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
_env_?: Record<string, string>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isBrowser = typeof window !== 'undefined';
|
|
||||||
|
|
||||||
const getDefaultEtherumProviderUrl = (env: Networks) => {
|
|
||||||
return env === Networks.MAINNET
|
|
||||||
? 'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'
|
|
||||||
: 'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDefaultEtherscanUrl = (env: Networks) => {
|
|
||||||
return env === Networks.MAINNET
|
|
||||||
? 'https://etherscan.io'
|
|
||||||
: 'https://sepolia.etherscan.io';
|
|
||||||
};
|
|
||||||
|
|
||||||
const transformValue = (key: EnvKey, value?: string) => {
|
|
||||||
switch (key) {
|
|
||||||
case 'VEGA_ENV':
|
|
||||||
return value as Networks;
|
|
||||||
case 'VEGA_NETWORKS': {
|
|
||||||
if (value) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(value);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(
|
|
||||||
'Error parsing the "NX_VEGA_NETWORKS" environment variable. Make sure it has a valid JSON format.'
|
|
||||||
);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
case 'MAINTENANCE_PAGE':
|
|
||||||
return ['true', '1', 'yes'].includes(value?.toLowerCase() || '');
|
|
||||||
default:
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBundledEnvironmentValue = (key: EnvKey) => {
|
|
||||||
switch (key) {
|
|
||||||
// need to have these hardcoded so on build time they can be replaced with the relevant environment variable
|
|
||||||
case 'VEGA_URL':
|
|
||||||
return process.env['NX_VEGA_URL'];
|
|
||||||
case 'VEGA_ENV':
|
|
||||||
return process.env['NX_VEGA_ENV'];
|
|
||||||
case 'VEGA_CONFIG_URL':
|
|
||||||
return process.env['NX_VEGA_CONFIG_URL'];
|
|
||||||
case 'ETHEREUM_PROVIDER_URL':
|
|
||||||
return process.env['NX_ETHEREUM_PROVIDER_URL'];
|
|
||||||
case 'ETHERSCAN_URL':
|
|
||||||
return process.env['NX_ETHERSCAN_URL'];
|
|
||||||
case 'VEGA_NETWORKS':
|
|
||||||
return process.env['NX_VEGA_NETWORKS'];
|
|
||||||
case 'GIT_BRANCH':
|
|
||||||
return process.env['NX_GIT_BRANCH'];
|
|
||||||
case 'GIT_COMMIT_HASH':
|
|
||||||
return process.env['NX_GIT_COMMIT_HASH'];
|
|
||||||
case 'GIT_ORIGIN_URL':
|
|
||||||
return process.env['NX_GIT_ORIGIN_URL'];
|
|
||||||
case 'GITHUB_FEEDBACK_URL':
|
|
||||||
return process.env['NX_GITHUB_FEEDBACK_URL'];
|
|
||||||
case 'VEGA_EXPLORER_URL':
|
|
||||||
return process.env['NX_VEGA_EXPLORER_URL'];
|
|
||||||
case 'VEGA_WALLET_URL':
|
|
||||||
return process.env['NX_VEGA_WALLET_URL'];
|
|
||||||
case 'VEGA_TOKEN_URL':
|
|
||||||
return process.env['NX_VEGA_TOKEN_URL'];
|
|
||||||
case 'VEGA_DOCS_URL':
|
|
||||||
return process.env['NX_VEGA_DOCS_URL'];
|
|
||||||
case 'HOSTED_WALLET_URL':
|
|
||||||
return process.env['NX_HOSTED_WALLET_URL'];
|
|
||||||
case 'ETH_LOCAL_PROVIDER_URL':
|
|
||||||
return process.env['NX_ETH_LOCAL_PROVIDER_URL'];
|
|
||||||
case 'ETH_WALLET_MNEMONIC':
|
|
||||||
return process.env['NX_ETH_WALLET_MNEMONIC'];
|
|
||||||
case 'MAINTENANCE_PAGE':
|
|
||||||
return process.env['NX_MAINTENANCE_PAGE'];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getValue = (key: EnvKey, definitions: Partial<RawEnvironment> = {}) => {
|
|
||||||
if (!isBrowser) {
|
|
||||||
return transformValue(
|
|
||||||
key,
|
|
||||||
definitions[key] ?? getBundledEnvironmentValue(key)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return transformValue(
|
|
||||||
key,
|
|
||||||
definitions[key] ??
|
|
||||||
window._env_?.[`NX_${key}`] ??
|
|
||||||
getBundledEnvironmentValue(key)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const compileEnvironment = (
|
|
||||||
definitions?: Partial<RawEnvironment>
|
|
||||||
): Environment => {
|
|
||||||
const environment = ENV_KEYS.reduce((acc, key) => {
|
|
||||||
const value = getValue(key, definitions);
|
|
||||||
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
[key]: value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {} as Environment);
|
|
||||||
|
|
||||||
const networkOverride = environment.VEGA_ENV
|
|
||||||
? {
|
|
||||||
[environment.VEGA_ENV]: isBrowser ? window.location.origin : undefined,
|
|
||||||
}
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// @ts-ignore enable using default object props
|
|
||||||
ETHERSCAN_URL: getDefaultEtherscanUrl(environment['VEGA_ENV']),
|
|
||||||
// @ts-ignore enable using default object props
|
|
||||||
ETHEREUM_PROVIDER_URL: getDefaultEtherumProviderUrl(
|
|
||||||
environment['VEGA_ENV']
|
|
||||||
),
|
|
||||||
...environment,
|
|
||||||
VEGA_NETWORKS: {
|
|
||||||
...networkOverride,
|
|
||||||
...environment.VEGA_NETWORKS,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,19 +1,32 @@
|
|||||||
import type { ZodIssue } from 'zod';
|
import type { ZodIssue } from 'zod';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a nice error string to be printed to the console
|
||||||
|
*/
|
||||||
export const compileErrors = (
|
export const compileErrors = (
|
||||||
headline: string,
|
headline: string,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
error: any,
|
error: any
|
||||||
compileIssue?: (issue: ZodIssue) => string
|
|
||||||
) => {
|
) => {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
return error.issues.reduce((acc, issue) => {
|
return error.issues.reduce((acc, issue) => {
|
||||||
return (
|
return acc + `\n - ${compileIssue(issue)}`;
|
||||||
acc + `\n - ${compileIssue ? compileIssue(issue) : issue.message}`
|
}, headline);
|
||||||
);
|
|
||||||
}, `${headline}:`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${headline}${error?.message ? `: ${error.message}` : ''}`;
|
return `${headline}${error?.message ? `: ${error.message}` : ''}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const compileIssue = (issue: ZodIssue) => {
|
||||||
|
switch (issue.code) {
|
||||||
|
case 'invalid_type':
|
||||||
|
return `NX_${issue.path[0]}: Received "${issue.received}" instead of: ${issue.expected}`;
|
||||||
|
case 'invalid_enum_value':
|
||||||
|
return `NX_${issue.path[0]}: Received "${
|
||||||
|
issue.received
|
||||||
|
}" instead of: ${issue.options.join(' | ')}`;
|
||||||
|
default:
|
||||||
|
return `${issue.path.join('.')} ${issue.message}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@ -1,55 +0,0 @@
|
|||||||
import type { Dispatch } from 'react';
|
|
||||||
import { ACTIONS } from '../hooks/use-nodes';
|
|
||||||
import type { Action } from '../hooks/use-nodes';
|
|
||||||
import { requestNode } from './request-node';
|
|
||||||
|
|
||||||
const getResponseTime = (url: string) => {
|
|
||||||
const requestUrl = new URL(url);
|
|
||||||
const requests = window.performance.getEntriesByName(requestUrl.href);
|
|
||||||
const { duration } = (requests.length && requests[requests.length - 1]) || {};
|
|
||||||
return duration;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const initializeNode = (
|
|
||||||
dispatch: Dispatch<Action>,
|
|
||||||
node: string,
|
|
||||||
nodeUrl?: string
|
|
||||||
) => {
|
|
||||||
let isMounted = true;
|
|
||||||
const url = nodeUrl ?? node;
|
|
||||||
|
|
||||||
dispatch({ type: ACTIONS.GET_STATISTICS, node, payload: { url } });
|
|
||||||
dispatch({ type: ACTIONS.CHECK_SUBSCRIPTION, node, payload: { url } });
|
|
||||||
|
|
||||||
const client = requestNode(url, {
|
|
||||||
onStatsSuccess: (data) => {
|
|
||||||
isMounted &&
|
|
||||||
dispatch({
|
|
||||||
type: ACTIONS.GET_STATISTICS_SUCCESS,
|
|
||||||
node,
|
|
||||||
payload: {
|
|
||||||
chain: data.statistics.chainId,
|
|
||||||
block: Number(data.statistics.blockHeight),
|
|
||||||
responseTime: getResponseTime(url),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onStatsFailure: () => {
|
|
||||||
isMounted && dispatch({ type: ACTIONS.GET_STATISTICS_FAILURE, node });
|
|
||||||
},
|
|
||||||
onSubscriptionSuccess: () => {
|
|
||||||
isMounted && dispatch({ type: ACTIONS.CHECK_SUBSCRIPTION_SUCCESS, node });
|
|
||||||
},
|
|
||||||
onSubscriptionFailure: () => {
|
|
||||||
isMounted && dispatch({ type: ACTIONS.CHECK_SUBSCRIPTION_FAILURE, node });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
client,
|
|
||||||
unsubscribe: () => {
|
|
||||||
client?.stop();
|
|
||||||
isMounted = false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
@ -10,6 +10,7 @@ export const statisticsQuery = (
|
|||||||
__typename: 'Statistics',
|
__typename: 'Statistics',
|
||||||
chainId: 'chain-id',
|
chainId: 'chain-id',
|
||||||
blockHeight: '11',
|
blockHeight: '11',
|
||||||
|
vegaTime: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,87 +0,0 @@
|
|||||||
import { StatisticsDocument, BlockTimeDocument } from './__generated__/Node';
|
|
||||||
import type {
|
|
||||||
StatisticsQuery,
|
|
||||||
BlockTimeSubscription,
|
|
||||||
} from './__generated__/Node';
|
|
||||||
import { createClient } from '@vegaprotocol/apollo-client';
|
|
||||||
|
|
||||||
type Callbacks = {
|
|
||||||
onStatsSuccess: (data: StatisticsQuery) => void;
|
|
||||||
onStatsFailure: () => void;
|
|
||||||
onSubscriptionSuccess: () => void;
|
|
||||||
onSubscriptionFailure: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUBSCRIPTION_TIMEOUT = 3000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes a single stats request and attempts a subscrition to VegaTime
|
|
||||||
* to determine whether or not a node is suitable for use
|
|
||||||
*/
|
|
||||||
export const requestNode = (
|
|
||||||
url: string,
|
|
||||||
{
|
|
||||||
onStatsSuccess,
|
|
||||||
onStatsFailure,
|
|
||||||
onSubscriptionSuccess,
|
|
||||||
onSubscriptionFailure,
|
|
||||||
}: Callbacks
|
|
||||||
) => {
|
|
||||||
// check url is a valid url
|
|
||||||
try {
|
|
||||||
new URL(url);
|
|
||||||
} catch (err) {
|
|
||||||
onStatsFailure();
|
|
||||||
onSubscriptionFailure();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let subscriptionSucceeded = false;
|
|
||||||
|
|
||||||
const client = createClient({
|
|
||||||
url,
|
|
||||||
retry: false,
|
|
||||||
connectToDevTools: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// make a query for block height
|
|
||||||
client
|
|
||||||
.query<StatisticsQuery>({
|
|
||||||
query: StatisticsDocument,
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
onStatsSuccess(res.data);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
onStatsFailure();
|
|
||||||
});
|
|
||||||
|
|
||||||
// start a subscription for VegaTime and await the first message
|
|
||||||
const subscription = client
|
|
||||||
.subscribe<BlockTimeSubscription>({
|
|
||||||
query: BlockTimeDocument,
|
|
||||||
errorPolicy: 'all',
|
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next() {
|
|
||||||
subscriptionSucceeded = true;
|
|
||||||
onSubscriptionSuccess();
|
|
||||||
subscription.unsubscribe();
|
|
||||||
},
|
|
||||||
error() {
|
|
||||||
onSubscriptionFailure();
|
|
||||||
subscription.unsubscribe();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// start a timeout, if the above subscription doesn't yield any messages
|
|
||||||
// before the timeout has completed consider it failed
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!subscriptionSucceeded) {
|
|
||||||
onSubscriptionFailure();
|
|
||||||
subscription.unsubscribe();
|
|
||||||
}
|
|
||||||
}, SUBSCRIPTION_TIMEOUT);
|
|
||||||
|
|
||||||
return client;
|
|
||||||
};
|
|
@ -1,19 +1,5 @@
|
|||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import type { Configuration } from '../types';
|
|
||||||
import { compileErrors } from './compile-errors';
|
|
||||||
|
|
||||||
export const configSchema = z.object({
|
export const configSchema = z.object({
|
||||||
hosts: z.array(z.string()),
|
hosts: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const validateConfiguration = (
|
|
||||||
config: Configuration
|
|
||||||
): string | undefined => {
|
|
||||||
try {
|
|
||||||
configSchema.parse(config);
|
|
||||||
return undefined;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (err: any) {
|
|
||||||
return compileErrors('Error processing the vega app configuration', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
@ -1,18 +1,5 @@
|
|||||||
import type { ZodIssue } from 'zod';
|
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import type { Environment } from '../types';
|
import { Networks } from '../types';
|
||||||
import { compileErrors } from './compile-errors';
|
|
||||||
|
|
||||||
export enum Networks {
|
|
||||||
CUSTOM = 'CUSTOM',
|
|
||||||
SANDBOX = 'SANDBOX',
|
|
||||||
TESTNET = 'TESTNET',
|
|
||||||
STAGNET1 = 'STAGNET1',
|
|
||||||
STAGNET3 = 'STAGNET3',
|
|
||||||
DEVNET = 'DEVNET',
|
|
||||||
MAINNET = 'MAINNET',
|
|
||||||
MIRROR = 'MIRROR',
|
|
||||||
}
|
|
||||||
|
|
||||||
const schemaObject = {
|
const schemaObject = {
|
||||||
VEGA_URL: z.optional(z.string()),
|
VEGA_URL: z.optional(z.string()),
|
||||||
@ -34,7 +21,7 @@ const schemaObject = {
|
|||||||
[env]: z.optional(z.string()),
|
[env]: z.optional(z.string()),
|
||||||
}),
|
}),
|
||||||
{}
|
{}
|
||||||
)
|
) as Record<Networks, z.ZodOptional<z.ZodString>>
|
||||||
)
|
)
|
||||||
.strict({
|
.strict({
|
||||||
message: `All keys in NX_VEGA_NETWORKS must represent a valid environment: ${Object.keys(
|
message: `All keys in NX_VEGA_NETWORKS must represent a valid environment: ${Object.keys(
|
||||||
@ -54,23 +41,8 @@ const schemaObject = {
|
|||||||
ETH_WALLET_MNEMONIC: z.optional(z.string()),
|
ETH_WALLET_MNEMONIC: z.optional(z.string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ENV_KEYS = Object.keys(schemaObject) as Array<
|
// combine schema above with custom rule to ensure either
|
||||||
keyof typeof schemaObject
|
// VEGA_URL or VEGA_CONFIG_URL are provided
|
||||||
>;
|
|
||||||
|
|
||||||
const compileIssue = (issue: ZodIssue) => {
|
|
||||||
switch (issue.code) {
|
|
||||||
case 'invalid_type':
|
|
||||||
return `NX_${issue.path[0]} is invalid, received "${issue.received}" instead of: ${issue.expected}`;
|
|
||||||
case 'invalid_enum_value':
|
|
||||||
return `NX_${issue.path[0]} is invalid, received "${
|
|
||||||
issue.received
|
|
||||||
}" instead of: ${issue.options.join(' | ')}`;
|
|
||||||
default:
|
|
||||||
return issue.message;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const envSchema = z.object(schemaObject).refine(
|
export const envSchema = z.object(schemaObject).refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
return !(!data.VEGA_URL && !data.VEGA_CONFIG_URL);
|
return !(!data.VEGA_URL && !data.VEGA_CONFIG_URL);
|
||||||
@ -80,19 +52,3 @@ export const envSchema = z.object(schemaObject).refine(
|
|||||||
'Must provide either NX_VEGA_CONFIG_URL or NX_VEGA_URL in the environment.',
|
'Must provide either NX_VEGA_CONFIG_URL or NX_VEGA_URL in the environment.',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const validateEnvironment = (
|
|
||||||
environment: Environment
|
|
||||||
): string | undefined => {
|
|
||||||
try {
|
|
||||||
envSchema.parse(environment);
|
|
||||||
return undefined;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (err: any) {
|
|
||||||
return compileErrors(
|
|
||||||
'Error processing the vega app environment',
|
|
||||||
err,
|
|
||||||
compileIssue
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
@ -1,121 +0,0 @@
|
|||||||
import { t } from '@vegaprotocol/react-helpers';
|
|
||||||
import { ErrorType } from '../types';
|
|
||||||
import type { Networks, NodeData } from '../types';
|
|
||||||
|
|
||||||
export const getIsNodeLoading = (node?: NodeData): boolean => {
|
|
||||||
if (!node) return false;
|
|
||||||
return (
|
|
||||||
node.chain.isLoading ||
|
|
||||||
node.responseTime.isLoading ||
|
|
||||||
node.block.isLoading ||
|
|
||||||
node.subscription.isLoading
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getIsInvalidUrl = (url: string) => {
|
|
||||||
try {
|
|
||||||
new URL(url);
|
|
||||||
return false;
|
|
||||||
} catch (err) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getIsNodeDisabled = (env: Networks, data?: NodeData) => {
|
|
||||||
return (
|
|
||||||
!!data &&
|
|
||||||
(getIsNodeLoading(data) ||
|
|
||||||
getIsInvalidUrl(data.url) ||
|
|
||||||
data.chain.hasError ||
|
|
||||||
data.responseTime.hasError ||
|
|
||||||
data.block.hasError ||
|
|
||||||
data.subscription.hasError)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getIsFormDisabled = (
|
|
||||||
currentNode: string | undefined,
|
|
||||||
env: Networks,
|
|
||||||
state: Record<string, NodeData>
|
|
||||||
) => {
|
|
||||||
if (!currentNode) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = state[currentNode];
|
|
||||||
return data ? getIsNodeDisabled(env, data) : true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getErrorByType = (
|
|
||||||
errorType: ErrorType | undefined | null,
|
|
||||||
env: Networks,
|
|
||||||
url?: string
|
|
||||||
) => {
|
|
||||||
switch (errorType) {
|
|
||||||
case ErrorType.INVALID_URL:
|
|
||||||
return {
|
|
||||||
headline: t('Error: invalid url'),
|
|
||||||
message: t(url ? `${url} is not a valid url.` : ''),
|
|
||||||
};
|
|
||||||
case ErrorType.SUBSCRIPTION_ERROR:
|
|
||||||
return {
|
|
||||||
headline: t(`Error: the node you are reading from does not emit data`),
|
|
||||||
message: t(
|
|
||||||
url
|
|
||||||
? `${url} is required to have subscriptions working to enable data updates on the page.`
|
|
||||||
: ''
|
|
||||||
),
|
|
||||||
};
|
|
||||||
case ErrorType.CONNECTION_ERROR:
|
|
||||||
return {
|
|
||||||
headline: t(`Error: can't connect to node`),
|
|
||||||
message: t(url ? `There was an error connecting to ${url}.` : ''),
|
|
||||||
};
|
|
||||||
case ErrorType.CONNECTION_ERROR_ALL:
|
|
||||||
return {
|
|
||||||
headline: t(`Error: can't connect to any of the nodes on the network`),
|
|
||||||
message: t(
|
|
||||||
`Please try entering a custom node address, or try again later.`
|
|
||||||
),
|
|
||||||
};
|
|
||||||
case ErrorType.CONFIG_VALIDATION_ERROR:
|
|
||||||
return {
|
|
||||||
headline: t(
|
|
||||||
`Error: the configuration found for the network ${env} is invalid`
|
|
||||||
),
|
|
||||||
message: t(
|
|
||||||
`Please try entering a custom node address, or try again later.`
|
|
||||||
),
|
|
||||||
};
|
|
||||||
case ErrorType.CONFIG_LOAD_ERROR:
|
|
||||||
return {
|
|
||||||
headline: t(`Error: can't load network configuration`),
|
|
||||||
message: t(
|
|
||||||
`You can try entering a custom node address, or try again later.`
|
|
||||||
),
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getErrorType = (env: Networks, data?: NodeData) => {
|
|
||||||
if (data && data.initialized) {
|
|
||||||
if (getIsInvalidUrl(data.url)) {
|
|
||||||
return ErrorType.INVALID_URL;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
data.chain.hasError ||
|
|
||||||
data.responseTime.hasError ||
|
|
||||||
data.block.hasError
|
|
||||||
) {
|
|
||||||
return ErrorType.CONNECTION_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.subscription.hasError) {
|
|
||||||
return ErrorType.SUBSCRIPTION_ERROR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
@ -1,7 +1,7 @@
|
|||||||
import { Fragment } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import { t } from '@vegaprotocol/react-helpers';
|
import { t } from '@vegaprotocol/react-helpers';
|
||||||
import { Link, Lozenge } from '@vegaprotocol/ui-toolkit';
|
import { Link, Lozenge } from '@vegaprotocol/ui-toolkit';
|
||||||
import { useEnvironment } from '@vegaprotocol/environment';
|
import { NodeSwitcherDialog, useEnvironment } from '@vegaprotocol/environment';
|
||||||
|
|
||||||
const getFeedbackLinks = (gitOriginUrl?: string) =>
|
const getFeedbackLinks = (gitOriginUrl?: string) =>
|
||||||
[
|
[
|
||||||
@ -18,59 +18,65 @@ export const NetworkInfo = () => {
|
|||||||
GIT_ORIGIN_URL,
|
GIT_ORIGIN_URL,
|
||||||
GITHUB_FEEDBACK_URL,
|
GITHUB_FEEDBACK_URL,
|
||||||
ETHEREUM_PROVIDER_URL,
|
ETHEREUM_PROVIDER_URL,
|
||||||
setNodeSwitcherOpen,
|
|
||||||
} = useEnvironment();
|
} = useEnvironment();
|
||||||
|
const [nodeSwitcherOpen, setNodeSwitcherOpen] = useState(false);
|
||||||
const feedbackLinks = getFeedbackLinks(GITHUB_FEEDBACK_URL);
|
const feedbackLinks = getFeedbackLinks(GITHUB_FEEDBACK_URL);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="git-info">
|
<>
|
||||||
<p data-testid="git-network-data" className="mb-2">
|
<div data-testid="git-info">
|
||||||
{t('Reading network data from')}{' '}
|
<p data-testid="git-network-data" className="mb-2">
|
||||||
<Lozenge className="bg-neutral-300 dark:bg-neutral-700">
|
{t('Reading network data from')}{' '}
|
||||||
{VEGA_URL}
|
<Lozenge className="bg-neutral-300 dark:bg-neutral-700">
|
||||||
</Lozenge>
|
{VEGA_URL}
|
||||||
. <Link onClick={() => setNodeSwitcherOpen()}>{t('Edit')}</Link>
|
</Lozenge>
|
||||||
</p>
|
. <Link onClick={() => setNodeSwitcherOpen(true)}>{t('Edit')}</Link>
|
||||||
<p data-testid="git-eth-data" className="mb-2 break-all">
|
|
||||||
{t('Reading Ethereum data from')}{' '}
|
|
||||||
<Lozenge className="bg-neutral-300 dark:bg-neutral-700">
|
|
||||||
{ETHEREUM_PROVIDER_URL}
|
|
||||||
</Lozenge>
|
|
||||||
.{' '}
|
|
||||||
</p>
|
|
||||||
{GIT_COMMIT_HASH && (
|
|
||||||
<p data-testid="git-commit-hash" className="mb-2">
|
|
||||||
{t('Version/commit hash')}:{' '}
|
|
||||||
<Link
|
|
||||||
href={
|
|
||||||
GIT_ORIGIN_URL
|
|
||||||
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
target={GIT_ORIGIN_URL ? '_blank' : undefined}
|
|
||||||
>
|
|
||||||
{GIT_COMMIT_HASH}
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<p data-testid="git-eth-data" className="mb-2 break-all">
|
||||||
{feedbackLinks.length > 0 && (
|
{t('Reading Ethereum data from')}{' '}
|
||||||
<p>
|
<Lozenge className="bg-neutral-300 dark:bg-neutral-700">
|
||||||
{t('Known issues and feedback on')}{' '}
|
{ETHEREUM_PROVIDER_URL}
|
||||||
{feedbackLinks.map(({ name, url }, index) => (
|
</Lozenge>
|
||||||
<Fragment key={index}>
|
.{' '}
|
||||||
<Link key={index} href={url}>
|
|
||||||
{name}
|
|
||||||
</Link>
|
|
||||||
{feedbackLinks.length > 1 &&
|
|
||||||
index < feedbackLinks.length - 2 &&
|
|
||||||
','}
|
|
||||||
{feedbackLinks.length > 1 &&
|
|
||||||
index === feedbackLinks.length - 1 &&
|
|
||||||
`, ${t('and')} `}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
{GIT_COMMIT_HASH && (
|
||||||
</div>
|
<p data-testid="git-commit-hash" className="mb-2">
|
||||||
|
{t('Version/commit hash')}:{' '}
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
GIT_ORIGIN_URL
|
||||||
|
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
target={GIT_ORIGIN_URL ? '_blank' : undefined}
|
||||||
|
>
|
||||||
|
{GIT_COMMIT_HASH}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{feedbackLinks.length > 0 && (
|
||||||
|
<p>
|
||||||
|
{t('Known issues and feedback on')}{' '}
|
||||||
|
{feedbackLinks.map(({ name, url }, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<Link key={index} href={url}>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
{feedbackLinks.length > 1 &&
|
||||||
|
index < feedbackLinks.length - 2 &&
|
||||||
|
','}
|
||||||
|
{feedbackLinks.length > 1 &&
|
||||||
|
index === feedbackLinks.length - 1 &&
|
||||||
|
`, ${t('and')} `}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<NodeSwitcherDialog
|
||||||
|
open={nodeSwitcherOpen}
|
||||||
|
setOpen={setNodeSwitcherOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -9,6 +9,7 @@ export * from './get-nodes';
|
|||||||
export * from './grid';
|
export * from './grid';
|
||||||
export * from './i18n';
|
export * from './i18n';
|
||||||
export * from './is-asset-erc20';
|
export * from './is-asset-erc20';
|
||||||
|
export * from './is-valid-url';
|
||||||
export * from './links';
|
export * from './links';
|
||||||
export * from './local-logger';
|
export * from './local-logger';
|
||||||
export * from './market-expires';
|
export * from './market-expires';
|
||||||
|
9
libs/react-helpers/src/lib/is-valid-url.ts
Normal file
9
libs/react-helpers/src/lib/is-valid-url.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export const isValidUrl = (url?: string) => {
|
||||||
|
if (!url) return false;
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
@ -18,7 +18,7 @@ import {
|
|||||||
ViewConnector,
|
ViewConnector,
|
||||||
WalletError,
|
WalletError,
|
||||||
} from '../connectors';
|
} from '../connectors';
|
||||||
import { EnvironmentProvider } from '@vegaprotocol/environment';
|
import { useEnvironment } from '@vegaprotocol/environment';
|
||||||
import type { ChainIdQuery } from '@vegaprotocol/react-helpers';
|
import type { ChainIdQuery } from '@vegaprotocol/react-helpers';
|
||||||
import { ChainIdDocument } from '@vegaprotocol/react-helpers';
|
import { ChainIdDocument } from '@vegaprotocol/react-helpers';
|
||||||
|
|
||||||
@ -30,6 +30,20 @@ const mockStoreObj: Partial<VegaWalletDialogStore> = {
|
|||||||
vegaWalletDialogOpen: true,
|
vegaWalletDialogOpen: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
jest.mock('@vegaprotocol/environment');
|
||||||
|
|
||||||
|
// @ts-ignore ignore mock implementation
|
||||||
|
useEnvironment.mockImplementation(() => ({
|
||||||
|
VEGA_ENV: 'TESTNET',
|
||||||
|
VEGA_URL: 'https://vega-node.url',
|
||||||
|
VEGA_NETWORKS: JSON.stringify({}),
|
||||||
|
VEGA_WALLET_URL: mockVegaWalletUrl,
|
||||||
|
GIT_BRANCH: 'test',
|
||||||
|
GIT_COMMIT_HASH: 'abcdef',
|
||||||
|
GIT_ORIGIN_URL: 'https://github.com/test/repo',
|
||||||
|
HOSTED_WALLET_URL: mockHostedWalletUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock('zustand', () => ({
|
jest.mock('zustand', () => ({
|
||||||
create: () => (storeGetter: (store: VegaWalletDialogStore) => unknown) =>
|
create: () => (storeGetter: (store: VegaWalletDialogStore) => unknown) =>
|
||||||
storeGetter(mockStoreObj as VegaWalletDialogStore),
|
storeGetter(mockStoreObj as VegaWalletDialogStore),
|
||||||
@ -56,16 +70,6 @@ beforeEach(() => {
|
|||||||
|
|
||||||
const mockVegaWalletUrl = 'http://mock.wallet.com';
|
const mockVegaWalletUrl = 'http://mock.wallet.com';
|
||||||
const mockHostedWalletUrl = 'http://mock.hosted.com';
|
const mockHostedWalletUrl = 'http://mock.hosted.com';
|
||||||
const mockEnvironment = {
|
|
||||||
VEGA_ENV: 'TESTNET',
|
|
||||||
VEGA_URL: 'https://vega-node.url',
|
|
||||||
VEGA_NETWORKS: JSON.stringify({}),
|
|
||||||
VEGA_WALLET_URL: mockVegaWalletUrl,
|
|
||||||
GIT_BRANCH: 'test',
|
|
||||||
GIT_COMMIT_HASH: 'abcdef',
|
|
||||||
GIT_ORIGIN_URL: 'https://github.com/test/repo',
|
|
||||||
HOSTED_WALLET_URL: mockHostedWalletUrl,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockChainId = 'chain-id';
|
const mockChainId = 'chain-id';
|
||||||
|
|
||||||
@ -83,13 +87,11 @@ function generateJSX(props?: Partial<VegaConnectDialogProps>) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<EnvironmentProvider definitions={mockEnvironment}>
|
<MockedProvider mocks={[chainIdMock]}>
|
||||||
<MockedProvider mocks={[chainIdMock]}>
|
<VegaWalletProvider>
|
||||||
<VegaWalletProvider>
|
<VegaConnectDialog {...defaultProps} {...props} />
|
||||||
<VegaConnectDialog {...defaultProps} {...props} />
|
</VegaWalletProvider>
|
||||||
</VegaWalletProvider>
|
</MockedProvider>
|
||||||
</MockedProvider>
|
|
||||||
</EnvironmentProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,6 +19,5 @@ export * from './lib/web3-provider';
|
|||||||
export * from './lib/web3-connectors';
|
export * from './lib/web3-connectors';
|
||||||
export * from './lib/web3-connect-dialog';
|
export * from './lib/web3-connect-dialog';
|
||||||
export * from './lib/web3-connect-store';
|
export * from './lib/web3-connect-store';
|
||||||
export * from './lib/web3-container';
|
|
||||||
export * from './lib/url-connector';
|
export * from './lib/url-connector';
|
||||||
export * from './lib/eip-1193-custom-bridge';
|
export * from './lib/eip-1193-custom-bridge';
|
||||||
|
@ -1,180 +0,0 @@
|
|||||||
import {
|
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
act,
|
|
||||||
waitFor,
|
|
||||||
} from '@testing-library/react';
|
|
||||||
import type { RenderResult } from '@testing-library/react';
|
|
||||||
import type { MockedResponse } from '@apollo/client/testing';
|
|
||||||
import { MockedProvider } from '@apollo/client/testing';
|
|
||||||
import { Web3Container } from './web3-container';
|
|
||||||
import { Web3ConnectUncontrolledDialog } from './web3-connect-dialog';
|
|
||||||
import type { useWeb3React } from '@web3-react/core';
|
|
||||||
import type { NetworkParamsQuery } from '@vegaprotocol/react-helpers';
|
|
||||||
import { NetworkParamsDocument } from '@vegaprotocol/react-helpers';
|
|
||||||
import { EnvironmentProvider } from '@vegaprotocol/environment';
|
|
||||||
|
|
||||||
const defaultHookValue = {
|
|
||||||
isActive: false,
|
|
||||||
error: undefined,
|
|
||||||
connector: null,
|
|
||||||
chainId: 11155111,
|
|
||||||
} as unknown as ReturnType<typeof useWeb3React>;
|
|
||||||
let mockHookValue: ReturnType<typeof useWeb3React>;
|
|
||||||
|
|
||||||
const mockEthereumConfig = {
|
|
||||||
network_id: '11155111',
|
|
||||||
chain_id: '11155111',
|
|
||||||
confirmations: 3,
|
|
||||||
collateral_bridge_contract: {
|
|
||||||
address: 'bridge address',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const networkParamsQueryMock: MockedResponse<NetworkParamsQuery> = {
|
|
||||||
request: {
|
|
||||||
query: NetworkParamsDocument,
|
|
||||||
},
|
|
||||||
result: {
|
|
||||||
data: {
|
|
||||||
networkParametersConnection: {
|
|
||||||
edges: [
|
|
||||||
{
|
|
||||||
node: {
|
|
||||||
__typename: 'NetworkParameter',
|
|
||||||
key: 'blockchains.ethereumConfig',
|
|
||||||
value: JSON.stringify(mockEthereumConfig),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironment = {
|
|
||||||
VEGA_ENV: 'TESTNET',
|
|
||||||
VEGA_URL: 'https://vega-node.url',
|
|
||||||
VEGA_NETWORKS: JSON.stringify({}),
|
|
||||||
GIT_BRANCH: 'test',
|
|
||||||
GIT_COMMIT_HASH: 'abcdef',
|
|
||||||
GIT_ORIGIN_URL: 'https://github.com/test/repo',
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock('@web3-react/core', () => {
|
|
||||||
const original = jest.requireActual('@web3-react/core');
|
|
||||||
return {
|
|
||||||
...original,
|
|
||||||
useWeb3React: jest.fn(() => mockHookValue),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
let renderResults: RenderResult;
|
|
||||||
async function setup(mock = networkParamsQueryMock) {
|
|
||||||
await act(async () => {
|
|
||||||
renderResults = await render(
|
|
||||||
<EnvironmentProvider definitions={mockEnvironment}>
|
|
||||||
<MockedProvider mocks={[mock]}>
|
|
||||||
<Web3Container>
|
|
||||||
<div>
|
|
||||||
<div>Child</div>
|
|
||||||
<div>{mockEthereumConfig.collateral_bridge_contract.address}</div>
|
|
||||||
</div>
|
|
||||||
</Web3Container>
|
|
||||||
</MockedProvider>
|
|
||||||
<Web3ConnectUncontrolledDialog />
|
|
||||||
</EnvironmentProvider>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return renderResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Web3Container', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
it('Prompt to connect opens dialog', async () => {
|
|
||||||
mockHookValue = defaultHookValue;
|
|
||||||
await setup();
|
|
||||||
await waitFor(async () => {
|
|
||||||
expect(
|
|
||||||
await screen.findByText('Connect your Ethereum wallet')
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.queryByText('Child')).not.toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.queryByTestId('web3-connector-list')
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
await act(() => {
|
|
||||||
fireEvent.click(screen.getByText('Connect'));
|
|
||||||
});
|
|
||||||
expect(screen.getByTestId('web3-connector-list')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Error message is shown', async () => {
|
|
||||||
const message = 'Opps! An error';
|
|
||||||
mockHookValue = { ...defaultHookValue, error: new Error(message) };
|
|
||||||
await setup();
|
|
||||||
|
|
||||||
await waitFor(async () => {
|
|
||||||
expect(
|
|
||||||
await screen.findByText(`Something went wrong: ${message}`)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Child')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Checks that chain ID matches app ID', async () => {
|
|
||||||
const expectedChainId = 4;
|
|
||||||
mockHookValue = {
|
|
||||||
...defaultHookValue,
|
|
||||||
isActive: true,
|
|
||||||
chainId: expectedChainId,
|
|
||||||
};
|
|
||||||
await setup();
|
|
||||||
expect(
|
|
||||||
await screen.findByText(`This app only works on Sepolia`)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Child')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Passes ethereum config to children', async () => {
|
|
||||||
mockHookValue = {
|
|
||||||
...defaultHookValue,
|
|
||||||
isActive: true,
|
|
||||||
};
|
|
||||||
await setup();
|
|
||||||
expect(
|
|
||||||
await screen.findByText(
|
|
||||||
mockEthereumConfig.collateral_bridge_contract.address
|
|
||||||
)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Shows no config found message if the network parameter doesnt exist', async () => {
|
|
||||||
const mock: MockedResponse<NetworkParamsQuery> = {
|
|
||||||
request: {
|
|
||||||
query: NetworkParamsDocument,
|
|
||||||
},
|
|
||||||
result: {
|
|
||||||
data: {
|
|
||||||
networkParametersConnection: {
|
|
||||||
edges: [
|
|
||||||
{
|
|
||||||
node: {
|
|
||||||
__typename: 'NetworkParameter',
|
|
||||||
key: 'nope',
|
|
||||||
value: 'foo',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await setup(mock);
|
|
||||||
expect(await screen.findByText('No data')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,147 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { useWeb3React } from '@web3-react/core';
|
|
||||||
import { AsyncRenderer, Button, Splash } from '@vegaprotocol/ui-toolkit';
|
|
||||||
import { t } from '@vegaprotocol/react-helpers';
|
|
||||||
import { useEnvironment } from '@vegaprotocol/environment';
|
|
||||||
import { Web3Provider } from './web3-provider';
|
|
||||||
import { useEthereumConfig } from './use-ethereum-config';
|
|
||||||
import { useWeb3ConnectStore } from './web3-connect-store';
|
|
||||||
import { createConnectors } from './web3-connectors';
|
|
||||||
import { getChainName } from './constants';
|
|
||||||
|
|
||||||
interface Web3ContainerProps {
|
|
||||||
children: ReactNode;
|
|
||||||
childrenOnly?: boolean;
|
|
||||||
connectEagerly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Web3Container = ({
|
|
||||||
children,
|
|
||||||
childrenOnly,
|
|
||||||
connectEagerly,
|
|
||||||
}: Web3ContainerProps) => {
|
|
||||||
const { config, loading, error } = useEthereumConfig();
|
|
||||||
const { ETHEREUM_PROVIDER_URL, ETH_LOCAL_PROVIDER_URL, ETH_WALLET_MNEMONIC } =
|
|
||||||
useEnvironment();
|
|
||||||
const connectors = useMemo(() => {
|
|
||||||
if (config?.chain_id) {
|
|
||||||
return createConnectors(
|
|
||||||
ETHEREUM_PROVIDER_URL,
|
|
||||||
Number(config?.chain_id),
|
|
||||||
ETH_LOCAL_PROVIDER_URL,
|
|
||||||
ETH_WALLET_MNEMONIC
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [
|
|
||||||
config?.chain_id,
|
|
||||||
ETHEREUM_PROVIDER_URL,
|
|
||||||
ETH_LOCAL_PROVIDER_URL,
|
|
||||||
ETH_WALLET_MNEMONIC,
|
|
||||||
]);
|
|
||||||
return (
|
|
||||||
<AsyncRenderer data={config} loading={loading} error={error}>
|
|
||||||
{connectors && config && (
|
|
||||||
<Web3Provider connectors={connectors}>
|
|
||||||
<Web3Content
|
|
||||||
connectEagerly={connectEagerly}
|
|
||||||
childrenOnly={childrenOnly}
|
|
||||||
appChainId={Number(config.chain_id)}
|
|
||||||
connectors={connectors}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Web3Content>
|
|
||||||
</Web3Provider>
|
|
||||||
)}
|
|
||||||
</AsyncRenderer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Web3ContentProps {
|
|
||||||
children: ReactNode;
|
|
||||||
childrenOnly?: boolean;
|
|
||||||
connectEagerly?: boolean;
|
|
||||||
appChainId: number;
|
|
||||||
connectors: ReturnType<typeof createConnectors>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Web3Content = ({
|
|
||||||
children,
|
|
||||||
childrenOnly,
|
|
||||||
connectEagerly,
|
|
||||||
appChainId,
|
|
||||||
connectors,
|
|
||||||
}: Web3ContentProps) => {
|
|
||||||
const { isActive, error, connector, chainId } = useWeb3React();
|
|
||||||
const openDialog = useWeb3ConnectStore((state) => state.open);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
connector?.connectEagerly &&
|
|
||||||
(!('Cypress' in window) || connectEagerly)
|
|
||||||
) {
|
|
||||||
connector.connectEagerly();
|
|
||||||
}
|
|
||||||
// wallet connect doesnt handle connectEagerly being called when connector is also in the
|
|
||||||
// deps array.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (childrenOnly) {
|
|
||||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<SplashWrapper>
|
|
||||||
<p className="mb-4">{t(`Something went wrong: ${error.message}`)}</p>
|
|
||||||
<Button onClick={() => connector.deactivate()}>
|
|
||||||
{t('Disconnect')}
|
|
||||||
</Button>
|
|
||||||
</SplashWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isActive) {
|
|
||||||
return (
|
|
||||||
<SplashWrapper>
|
|
||||||
<p data-testid="connect-eth-wallet-msg" className="mb-4">
|
|
||||||
{t('Connect your Ethereum wallet')}
|
|
||||||
</p>
|
|
||||||
<Button onClick={openDialog} data-testid="connect-eth-wallet-btn">
|
|
||||||
{t('Connect')}
|
|
||||||
</Button>
|
|
||||||
</SplashWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chainId !== appChainId) {
|
|
||||||
return (
|
|
||||||
<SplashWrapper>
|
|
||||||
<p className="mb-4">
|
|
||||||
{t(`This app only works on ${getChainName(appChainId)}`)}
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => connector.deactivate()}>
|
|
||||||
{t('Disconnect')}
|
|
||||||
</Button>
|
|
||||||
</SplashWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SplashWrapperProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SplashWrapper = ({ children }: SplashWrapperProps) => {
|
|
||||||
return (
|
|
||||||
<Splash>
|
|
||||||
<div className="text-center">{children}</div>
|
|
||||||
</Splash>
|
|
||||||
);
|
|
||||||
};
|
|
Loading…
Reference in New Issue
Block a user