feat(trading): datanode block header for env and node switcher (#2905)

This commit is contained in:
Matthew Russell 2023-02-15 19:52:54 -08:00 committed by GitHub
parent 64c92ce91d
commit a82509f0e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 2201 additions and 4267 deletions

View File

@ -1,5 +1,5 @@
import classnames from 'classnames';
import { EnvironmentProvider, NetworkLoader } from '@vegaprotocol/environment';
import { NetworkLoader, useInitializeEnv } from '@vegaprotocol/environment';
import { Nav } from './components/nav';
import { Header } from './components/header';
import { Main } from './components/main';
@ -61,11 +61,8 @@ function App() {
}
const Wrapper = () => {
return (
<EnvironmentProvider>
<App />
</EnvironmentProvider>
);
useInitializeEnv();
return <App />;
};
export default Wrapper;

View File

@ -1,39 +1,46 @@
import { useEnvironment } from '@vegaprotocol/environment';
import { NodeSwitcherDialog, useEnvironment } from '@vegaprotocol/environment';
import { t } from '@vegaprotocol/react-helpers';
import { Link } from '@vegaprotocol/ui-toolkit';
import { useState } from 'react';
export const Footer = () => {
const { VEGA_URL, GIT_COMMIT_HASH, GIT_ORIGIN_URL, setNodeSwitcherOpen } =
useEnvironment();
const { VEGA_URL, GIT_COMMIT_HASH, GIT_ORIGIN_URL } = useEnvironment();
const [nodeSwitcherOpen, setNodeSwitcherOpen] = useState(false);
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">
<div className="content-center flex border-r border-neutral-700 dark:border-neutral-300 pr-4">
{GIT_COMMIT_HASH && (
<p data-testid="git-commit-hash">
{t('Version')}:{' '}
<Link
href={
GIT_ORIGIN_URL
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
: undefined
}
target={GIT_ORIGIN_URL ? '_blank' : undefined}
>
{GIT_COMMIT_HASH}
</Link>
</p>
)}
</div>
<>
<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">
<div className="content-center flex border-r border-neutral-700 dark:border-neutral-300 pr-4">
{GIT_COMMIT_HASH && (
<p data-testid="git-commit-hash">
{t('Version')}:{' '}
<Link
href={
GIT_ORIGIN_URL
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
: undefined
}
target={GIT_ORIGIN_URL ? '_blank' : undefined}
>
{GIT_COMMIT_HASH}
</Link>
</p>
)}
</div>
<div className="flex pl-2 content-center">
{VEGA_URL && <NodeUrl url={VEGA_URL} />}
<Link className="ml-2" onClick={setNodeSwitcherOpen}>
{t('Change')}
</Link>
<div className="flex pl-2 content-center">
{VEGA_URL && <NodeUrl url={VEGA_URL} />}
<Link className="ml-2" onClick={() => setNodeSwitcherOpen(true)}>
{t('Change')}
</Link>
</div>
</div>
</div>
</footer>
</footer>
<NodeSwitcherDialog
open={nodeSwitcherOpen}
setOpen={setNodeSwitcherOpen}
/>
</>
);
};

View File

@ -1,3 +1,5 @@
import type { InMemoryCacheConfig } from '@apollo/client';
import { NetworkLoader, useInitializeEnv } from '@vegaprotocol/environment';
import { useRoutes } from 'react-router-dom';
import '../styles.scss';
@ -5,14 +7,40 @@ import { Navbar } from './components/navbar';
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);
export function App() {
useInitializeEnv();
return (
<div className="max-h-full min-h-full bg-white">
<Navbar />
<AppRouter />
</div>
<NetworkLoader cache={cache}>
<div className="max-h-full min-h-full bg-white">
<Navbar />
<AppRouter />
</div>
</NetworkLoader>
);
}

View File

@ -1,44 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { EnvironmentProvider, NetworkLoader } from '@vegaprotocol/environment';
import App from './app/app';
import type { InMemoryCacheConfig } from '@apollo/client';
const rootElement = document.getElementById('root');
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(
<StrictMode>
<BrowserRouter>
<EnvironmentProvider>
<NetworkLoader cache={cache}>
<App />
</NetworkLoader>
</EnvironmentProvider>
<App />
</BrowserRouter>
</StrictMode>
);

View File

@ -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_NETWORKS='{"TESTNET":"https://multisig-signer.fairground.wtf","MAINNET":"https://multisig-signer.vega.xyz"}'
NX_VEGA_ENV=STAGNET3

View File

@ -3,9 +3,9 @@ import classnames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import { BrowserTracing } from '@sentry/tracing';
import {
EnvironmentProvider,
NetworkLoader,
useEnvironment,
useInitializeEnv,
} from '@vegaprotocol/environment';
import { AsyncRenderer, Button, Lozenge } from '@vegaprotocol/ui-toolkit';
import type { EthereumConfig } from '@vegaprotocol/web3';
@ -64,6 +64,7 @@ function App() {
environment: VEGA_ENV,
});
}, [VEGA_ENV]);
const Connectors = useMemo(() => {
if (config?.chain_id) {
return createConnectors(ETHEREUM_PROVIDER_URL, Number(config.chain_id));
@ -107,12 +108,11 @@ const Wrapper = () => {
},
},
};
useInitializeEnv();
return (
<EnvironmentProvider>
<NetworkLoader cache={cache}>
<App />
</NetworkLoader>
</EnvironmentProvider>
<NetworkLoader cache={cache}>
<App />
</NetworkLoader>
);
};

View File

@ -24,8 +24,8 @@ import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { useEthereumConfig } from '@vegaprotocol/web3';
import {
useEnvironment,
EnvironmentProvider,
NetworkLoader,
useInitializeEnv,
} from '@vegaprotocol/environment';
import { createConnectors } from './lib/web3-connectors';
import { ENV } from './config/env';
@ -166,12 +166,12 @@ const AppContainer = () => {
};
function App() {
useInitializeEnv();
return (
<EnvironmentProvider>
<NetworkLoader cache={cache}>
<AppContainer />
</NetworkLoader>
</EnvironmentProvider>
<NetworkLoader cache={cache}>
<AppContainer />
</NetworkLoader>
);
}

View File

@ -1,23 +1,18 @@
import { render, screen, within } from '@testing-library/react';
import { EnvironmentProvider, Networks } from '@vegaprotocol/environment';
import { MemoryRouter } from 'react-router-dom';
import { Nav } from './nav';
jest.mock('@vegaprotocol/environment', () => ({
...jest.requireActual('@vegaprotocol/environment'),
NetworkSwitcher: () => <div data-testid="network-switcher" />,
useEnvironment: () => ({ VEGA_ENV: 'MAINNET' }),
}));
const renderComponent = (initialEntries?: string[]) => {
return render(
<EnvironmentProvider
definitions={{ VEGA_ENV: Networks.MAINNET }}
config={{ hosts: [] }}
>
<MemoryRouter initialEntries={initialEntries}>
<Nav />
</MemoryRouter>
</EnvironmentProvider>
<MemoryRouter initialEntries={initialEntries}>
<Nav />
</MemoryRouter>
);
};

View File

@ -145,8 +145,9 @@ describe('markets table', { tags: '@smoke' }, () => {
proposal: { terms: { enactmentDatetime: '2023-01-31 12:00:01' } },
});
});
cy.visit('/');
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('opening-auction-sub-status').should(
'contain.text',
@ -155,8 +156,7 @@ describe('markets table', { tags: '@smoke' }, () => {
const now = new Date(Date.parse('2023-01-30 12:00:01')).getTime();
cy.clock(now, ['Date']); // Set "now" to BEFORE reservation
cy.visit('/');
cy.visit('#/markets/market-0');
cy.reload();
cy.getByTestId('item-value').contains('Opening auction').realHover();
cy.getByTestId('opening-auction-sub-status').should(
'contain.text',

View 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>
);
};

View 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,
},
},
};

View File

@ -1,121 +1,3 @@
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { NetworkLoader, useEnvironment } from '@vegaprotocol/environment';
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,
},
},
};
export * from './app-failure';
export * from './app-loader';
export * from './web3-provider';

View 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>
);
};

View File

@ -1,57 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { Footer, NodeHealth } from './footer';
import { useEnvironment } from '@vegaprotocol/environment';
import { NodeUrl, NodeHealth } from './footer';
jest.mock('@vegaprotocol/environment');
describe('Footer', () => {
describe('NodeUrl', () => {
it('can open node switcher by clicking the node url', () => {
const mockOpenNodeSwitcher = jest.fn();
const node = 'n99.somenetwork.vega.xyz';
const node = 'https://api.n99.somenetwork.vega.xyz';
// @ts-ignore mock env hook
useEnvironment.mockImplementation(() => ({
VEGA_URL: `https://api.${node}/graphql`,
blockDifference: 0,
setNodeSwitcherOpen: mockOpenNodeSwitcher,
}));
render(<NodeUrl url={node} openNodeSwitcher={mockOpenNodeSwitcher} />);
render(<Footer />);
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'));
fireEvent.click(screen.getByText(/n99/));
expect(mockOpenNodeSwitcher).toHaveBeenCalled();
});
});
describe('NodeHealth', () => {
const mockOpenNodeSwitcher = jest.fn();
const cases = [
{ diff: 0, classname: 'bg-vega-green-550', text: 'Operational' },
{ 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)(
'renders correct text and indicator color for $diff block difference',
(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.getByText(elem.text)).toBeInTheDocument();
fireEvent.click(screen.getByText(elem.text));
expect(mockOpenNodeSwitcher).toHaveBeenCalled();
}
);
});

View File

@ -1,9 +1,15 @@
import { useEnvironment } from '@vegaprotocol/environment';
import { useEnvironment, useNodeHealth } from '@vegaprotocol/environment';
import { t, useNavigatorOnline } from '@vegaprotocol/react-helpers';
import { ButtonLink, Indicator, Intent } from '@vegaprotocol/ui-toolkit';
import { useGlobalStore } from '../../stores';
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 (
<footer className="px-4 py-1 text-xs border-t border-default">
<div className="flex justify-between">
@ -11,11 +17,14 @@ export const Footer = () => {
{VEGA_URL && (
<>
<NodeHealth
blockDiff={blockDifference}
openNodeSwitcher={setNodeSwitcherOpen}
blockDiff={blockDiff}
openNodeSwitcher={() => setNodeSwitcher(true)}
/>
{' | '}
<NodeUrl url={VEGA_URL} openNodeSwitcher={setNodeSwitcherOpen} />
<NodeUrl
url={VEGA_URL}
openNodeSwitcher={() => setNodeSwitcher(true)}
/>
</>
)}
</div>
@ -29,7 +38,7 @@ interface NodeUrlProps {
openNodeSwitcher: () => void;
}
const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => {
export const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => {
// get base url from api url, api sub domain
const urlObj = new URL(url);
const nodeUrl = urlObj.origin.replace(/^[^.]+\./g, '');
@ -38,7 +47,7 @@ const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => {
interface NodeHealthProps {
openNodeSwitcher: () => void;
blockDiff: number;
blockDiff: number | null;
}
// How many blocks behind the most advanced block that is
@ -57,7 +66,7 @@ export const NodeHealth = ({
if (!online) {
text = t('Offline');
intent = Intent.Danger;
} else if (blockDiff < 0) {
} else if (blockDiff === null) {
// Block height query failed and null was returned
text = t('Non operational');
intent = Intent.Danger;
@ -67,9 +76,9 @@ export const NodeHealth = ({
}
return (
<span>
<>
<Indicator variant={intent} />
<ButtonLink onClick={openNodeSwitcher}>{text}</ButtonLink>
</span>
</>
);
};

View File

@ -1,10 +1,12 @@
import { BrowserRouter } from 'react-router-dom';
import { render, screen, fireEvent } from '@testing-library/react';
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 { WelcomeDialog } from './welcome-dialog';
jest.mock('@vegaprotocol/environment');
const mockEnvDefinitions = {
VEGA_CONFIG_URL: 'https://config.url',
VEGA_URL: 'https://test.url',
@ -28,15 +30,16 @@ describe('Risk notice dialog', () => {
`(
'$assertion the risk notice on $network',
async ({ assertion, network }) => {
// @ts-ignore ignore mock implementation
useEnvironment.mockImplementation(() => ({
...mockEnvDefinitions,
VEGA_ENV: network,
}));
render(
<EnvironmentProvider
definitions={{ ...mockEnvDefinitions, VEGA_ENV: network }}
config={{ hosts: [] }}
>
<MockedProvider>
<WelcomeDialog />
</MockedProvider>
</EnvironmentProvider>,
<MockedProvider>
<WelcomeDialog />
</MockedProvider>,
{ wrapper: BrowserRouter }
);
@ -51,14 +54,13 @@ describe('Risk notice dialog', () => {
);
it("doesn't display the risk notice when previously acknowledged", () => {
render(
<EnvironmentProvider
definitions={{ ...mockEnvDefinitions, VEGA_ENV: Networks.MAINNET }}
config={{ hosts: [] }}
>
<RiskNoticeDialog onClose={mockOnClose} />
</EnvironmentProvider>
);
// @ts-ignore ignore mock implementation
useEnvironment.mockImplementation(() => ({
...mockEnvDefinitions,
VEGA_ENV: Networks.MAINNET,
}));
render(<RiskNoticeDialog onClose={mockOnClose} />);
expect(screen.queryByText(introText)).toBeInTheDocument();

View File

@ -1,11 +1,9 @@
import Head from 'next/head';
import dynamic from 'next/dynamic';
import type { AppProps } from 'next/app';
import { Navbar } from '../components/navbar';
import { t } from '@vegaprotocol/react-helpers';
import {
useEagerConnect as useVegaEagerConnect,
VegaWalletProvider,
useVegaTransactionManager,
useVegaTransactionUpdater,
useVegaWallet,
@ -17,17 +15,17 @@ import {
useEthWithdrawApprovalsManager,
} from '@vegaprotocol/web3';
import {
EnvironmentProvider,
envTriggerMapping,
Networks,
NodeSwitcherDialog,
useEnvironment,
useInitializeEnv,
} from '@vegaprotocol/environment';
import { AppLoader, Web3Provider } from '../components/app-loader';
import './styles.css';
import './gen-styles.scss';
import { usePageTitleStore } from '../stores';
import { useGlobalStore, usePageTitleStore } from '../stores';
import { Footer } from '../components/footer';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import DialogsContainer from './dialogs-container';
import ToastsManager from './toasts-manager';
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 { Banner } from '../components/banner';
import classNames from 'classnames';
import { AppLoader } from '../components/app-loader';
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" />
</Head>
<Title />
<VegaWalletProvider>
<AppLoader>
<Web3Provider>
<div className={gridClasses}>
<Navbar
navbarTheme={VEGA_ENV === Networks.TESTNET ? 'yellow' : 'dark'}
/>
<Banner />
<ViewingBanner />
<main data-testid={location.pathname}>
<Component />
</main>
<Footer />
</div>
<DialogsContainer />
<ToastsManager />
<TransactionsHandler />
<MaybeConnectEagerly />
</Web3Provider>
</AppLoader>
</VegaWalletProvider>
<div className={gridClasses}>
<Navbar
navbarTheme={VEGA_ENV === Networks.TESTNET ? 'yellow' : 'dark'}
/>
<Banner />
<ViewingBanner />
<main data-testid={location.pathname}>
<Component />
</main>
<Footer />
</div>
<DialogsContainer />
<ToastsManager />
<TransactionsHandler />
<MaybeConnectEagerly />
</div>
);
}
const DynamicLoader = dynamic(
() => import('../components/preloader/preloader'),
{
loading: () => <>Loading...</>,
}
);
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
// so we need to ensure client side rendering only from this point onwards in
// the component tree
useEffect(() => {
setMounted(true);
}, []);
useInitializeEnv();
if (!mounted) {
return <DynamicLoader />;
// Prevent HashRouter from being server side rendered as it
// relies on presence of document object
if (status === 'default') {
return null;
}
return (
<HashRouter>
<EnvironmentProvider>
<AppLoader>
<AppBody {...props} />
</EnvironmentProvider>
</AppLoader>
<NodeSwitcherDialog open={nodeSwitcherOpen} setOpen={setNodeSwitcher} />
</HashRouter>
);
}

View File

@ -3,7 +3,7 @@ import { create } from 'zustand';
import produce from 'immer';
interface GlobalStore {
networkSwitcherDialog: boolean;
nodeSwitcherDialog: boolean;
marketId: string | null;
update: (store: Partial<Omit<GlobalStore, 'update'>>) => void;
shouldDisplayWelcomeDialog: boolean;
@ -16,7 +16,7 @@ interface PageTitleStore {
}
export const useGlobalStore = create<GlobalStore>((set) => ({
networkSwitcherDialog: false,
nodeSwitcherDialog: false,
marketId: LocalStorage.getItem('marketId') || null,
shouldDisplayWelcomeDialog: false,
shouldDisplayAnnouncementBanner: true,

View File

@ -1,2 +1,3 @@
export * from './lib/apollo-client';
export * from './cache-config';
export * from './lib/header-store';

View File

@ -14,6 +14,7 @@ import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import ApolloLinkTimeout from 'apollo-link-timeout';
import { localLoggerFactory } from '@vegaprotocol/react-helpers';
import { useHeaderStore } from './header-store';
const isBrowser = typeof window !== 'undefined';
@ -24,6 +25,7 @@ export type ClientOptions = {
cacheConfig?: InMemoryCacheConfig;
retry?: boolean;
connectToDevTools?: boolean;
connectToHeaderStore?: boolean;
};
export function createClient({
@ -31,6 +33,7 @@ export function createClient({
cacheConfig,
retry = true,
connectToDevTools = true,
connectToHeaderStore = true,
}: ClientOptions) {
if (!url) {
throw new Error('url must be passed into createClient!');
@ -47,6 +50,28 @@ export function createClient({
const timeoutLink = new ApolloLinkTimeout(10000);
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
? new RetryLink({
delay: {
@ -104,7 +129,13 @@ export function createClient({
);
return new ApolloClient({
link: from([errorLink, composedTimeoutLink, retryLink, splitLink]),
link: from([
errorLink,
composedTimeoutLink,
retryLink,
headerLink,
splitLink,
]),
cache: new InMemoryCache(cacheConfig),
connectToDevTools,
});

View 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>(() => ({}));

View 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()));
});

View File

@ -1,4 +1,4 @@
export * from './network-loader';
export * from './network-switcher';
export * from './node-guard';
export * from './node-switcher';
export * from './node-switcher-dialog';

View File

@ -23,6 +23,7 @@ describe('Network loader', () => {
// @ts-ignore Typescript doesn't recognise mocked instances
useEnvironment.mockImplementation(() => ({
VEGA_URL: undefined,
status: 'success',
}));
render(
@ -30,7 +31,7 @@ describe('Network loader', () => {
);
expect(screen.getByText(SKELETON_TEXT)).toBeInTheDocument();
expect(() => screen.getByText(SUCCESS_TEXT)).toThrow();
expect(screen.queryByText(SUCCESS_TEXT)).not.toBeInTheDocument();
expect(createClient).not.toHaveBeenCalled();
});
@ -41,6 +42,7 @@ describe('Network loader', () => {
// @ts-ignore Typescript doesn't recognise mocked instances
useEnvironment.mockImplementation(() => ({
VEGA_URL: 'http://vega.node',
status: 'success',
}));
render(
@ -50,6 +52,6 @@ describe('Network loader', () => {
url: 'http://vega.node',
cacheConfig: undefined,
});
expect(await screen.findByText(SUCCESS_TEXT)).toBeInTheDocument();
expect(screen.getByText(SUCCESS_TEXT)).toBeInTheDocument();
});
});

View File

@ -8,32 +8,40 @@ import { createClient } from '@vegaprotocol/apollo-client';
type NetworkLoaderProps = {
children?: ReactNode;
skeleton?: ReactNode;
failure?: ReactNode;
cache?: InMemoryCacheConfig;
};
export function NetworkLoader({
skeleton,
failure,
children,
cache,
}: NetworkLoaderProps) {
const { VEGA_URL } = useEnvironment();
const { status, VEGA_URL } = useEnvironment((store) => ({
status: store.status,
VEGA_URL: store.VEGA_URL,
}));
const client = useMemo(() => {
if (VEGA_URL) {
if (status === 'success' && VEGA_URL) {
return createClient({
url: VEGA_URL,
cacheConfig: cache,
});
}
return undefined;
}, [VEGA_URL, cache]);
}, [VEGA_URL, status, cache]);
if (!client) {
return (
<div className="h-full min-h-screen flex items-center justify-center">
{skeleton}
</div>
);
const nonIdealWrapperClasses =
'h-full min-h-screen flex items-center justify-center';
if (status === 'failed') {
return <div className={nonIdealWrapperClasses}>{failure}</div>;
}
if (status === 'default' || status === 'pending' || !client) {
return <div className={nonIdealWrapperClasses}>{skeleton}</div>;
}
return <ApolloProvider client={client}>{children}</ApolloProvider>;

View File

@ -0,0 +1 @@
export * from './node-guard';

View 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}</>;
};

View File

@ -1 +0,0 @@
export * from './node-switcher-dialog';

View File

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

View File

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

View File

@ -1 +1,2 @@
export * from './node-switcher-dialog';
export * from './node-switcher';

View File

@ -9,7 +9,7 @@ export const LayoutRow = ({ children, dataTestId }: LayoutRowProps) => {
return (
<div
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}
</div>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,197 +1,237 @@
import { useState } from 'react';
import { t } from '@vegaprotocol/react-helpers';
import { useCallback, useState } from 'react';
import { isValidUrl, t } from '@vegaprotocol/react-helpers';
import {
RadioGroup,
Button,
ButtonLink,
Input,
Link,
Loader,
Radio,
RadioGroup,
} from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '../../hooks/use-environment';
import { useNodes } from '../../hooks/use-nodes';
import {
getIsNodeLoading,
getIsNodeDisabled,
getIsFormDisabled,
getErrorType,
getErrorByType,
} from '../../utils/validate-node';
import { useEnvironment } from '../../hooks';
import { CUSTOM_NODE_KEY } from '../../types';
import type { Configuration, NodeData, ErrorType } from '../../types';
import { LayoutCell } from './layout-cell';
import { LayoutRow } from './layout-row';
import { NodeError } from './node-error';
import { NodeStats } from './node-stats';
import { ApolloWrapper } from './apollo-wrapper';
import { RowData } from './row-data';
type NodeSwitcherProps = {
error?: string;
config: Configuration;
initialErrorType?: ErrorType;
onConnect: (url: string) => void;
};
const getDefaultNode = (urls: string[], currentUrl?: string) => {
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)
export const NodeSwitcher = ({ closeDialog }: { closeDialog: () => void }) => {
const { nodes, setUrl, status, VEGA_ENV, VEGA_URL } = useEnvironment(
(store) => ({
status: store.status,
nodes: store.nodes,
setUrl: store.setUrl,
VEGA_ENV: store.VEGA_ENV,
VEGA_URL: store.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 onSubmit = (node: ReturnType<typeof getDefaultNode>) => {
if (node && state[node]) {
onConnect(state[node].url);
const [nodeRadio, setNodeRadio] = useState<string>(() => {
if (VEGA_URL) {
return VEGA_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 =
nodeRadio &&
state[CUSTOM_NODE_KEY] &&
state[CUSTOM_NODE_KEY].url === customNodeText
? state[nodeRadio]
: undefined;
const customNodeError = getErrorByType(
getErrorType(VEGA_ENV, customNodeData),
VEGA_ENV,
customUrl
);
let isDisabled = false;
if (nodeRadio === '') {
isDisabled = true;
} else if (nodeRadio === VEGA_URL) {
isDisabled = true;
} else if (nodeRadio === CUSTOM_NODE_KEY) {
if (!isValidUrl(customUrlText)) {
isDisabled = true;
}
}
return (
<div className="text-black dark:text-white w-full lg:min-w-[800px]">
<NodeError {...(customNodeError || networkError)} />
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(nodeRadio);
}}
>
<p className="text-lg mt-4">
{t('Select a GraphQL node to connect to:')}
</p>
<div className="mb-2">
<div className="hidden lg:block">
<LayoutRow>
<div />
<span className="text-right">{t('Response time')}</span>
<span className="text-right">{t('Block')}</span>
<span className="text-right">{t('Subscription')}</span>
</LayoutRow>
</div>
<div>
<h3 className="uppercase text-xl calt text-center mb-2">
{t('Connected node')}
</h3>
{status === 'pending' ? (
<div className="py-8">
<p className="mb-4 text-center">{t('Loading configuration...')}</p>
<Loader size="large" />
</div>
) : (
<div>
<p className="mb-2 text-sm text-center">
{t(
`This app will only work on ${VEGA_ENV}. Select a node to connect to.`
)}
</p>
<RadioGroup
value={nodeRadio}
onChange={(value) => {
setNodeRadio(value);
setNetworkError(null);
}}
onChange={(value) => setNodeRadio(value)}
>
<div className="w-full">
{config.hosts.map((node, index) => (
<NodeStats
key={index}
data={state[node]}
client={clients[node]}
<div className="hidden lg:block">
<LayoutRow>
<span>{t('Node')}</span>
<span className="text-right">{t('Response time')}</span>
<span className="text-right">{t('Block')}</span>
<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}
setBlock={(block) => updateNodeBlock(node, block)}
>
<div className="break-all" data-testid="node">
<Radio
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>
onBlockHeight={handleHighestBlock}
nodeRadio={nodeRadio}
/>
</div>
</div>
</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>
<Button
disabled={isSubmitDisabled}
fill={true}
type="submit"
data-testid="connect"
>
{t('Connect')}
</Button>
</div>
</form>
)}
</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>
);
};

View File

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

View 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);
});
});

View 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 '-';
};

View File

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

View File

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

View File

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

View File

@ -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("' | '")}'`
);
});
});

View 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);
});
});

View File

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

View 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';
}

View File

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

View File

@ -1,117 +1,114 @@
import { act, renderHook } from '@testing-library/react';
import {
useNodeHealth,
NODE_SUBSET_COUNT,
INTERVAL_TIME,
} from './use-node-health';
import type { createClient } from '@vegaprotocol/apollo-client';
import type { ClientCollection } from './use-nodes';
import { renderHook, waitFor } from '@testing-library/react';
import { useNodeHealth } from './use-node-health';
import type { MockedResponse } from '@apollo/react-testing';
import { MockedProvider } from '@apollo/react-testing';
import type { StatisticsQuery } from '../utils/__generated__/Node';
import { StatisticsDocument } from '../utils/__generated__/Node';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
function setup(...args: Parameters<typeof useNodeHealth>) {
return renderHook(() => useNodeHealth(...args));
}
const vegaUrl = 'https://foo.bar.com';
function createMockClient(blockHeight: number) {
jest.mock('./use-environment', () => ({
useEnvironment: () => vegaUrl,
}));
jest.mock('@vegaprotocol/apollo-client');
const createStatsMock = (
blockHeight: number
): MockedResponse<StatisticsQuery> => {
return {
query: jest.fn().mockResolvedValue({
request: {
query: StatisticsDocument,
},
result: {
data: {
statistics: {
chainId: 'chain-id',
blockHeight: blockHeight.toString(),
vegaTime: '12345',
},
},
}),
} as unknown as ReturnType<typeof createClient>;
}
},
};
};
function createRejectingClient() {
return {
query: () => Promise.reject(new Error('request failed')),
} as unknown as ReturnType<typeof createClient>;
}
function setup(
mock: MockedResponse<StatisticsQuery>,
headers:
| {
blockHeight: number;
timestamp: Date;
}
| undefined
) {
// @ts-ignore ignore mock implementation
useHeaderStore.mockImplementation(() => ({
[vegaUrl]: headers,
}));
function createErroringClient() {
return {
query: () =>
Promise.resolve({
error: new Error('failed'),
}),
} as unknown as ReturnType<typeof createClient>;
return renderHook(() => useNodeHealth(), {
wrapper: ({ children }) => (
<MockedProvider mocks={[mock]}>{children}</MockedProvider>
),
});
}
const CURRENT_URL = 'https://current.test.com';
describe('useNodeHealth', () => {
beforeAll(() => {
jest.useFakeTimers();
});
it.each([
{ 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 () => {
const highest = 100;
const curr = 97;
const clientCollection: ClientCollection = {
[CURRENT_URL]: createMockClient(curr),
'https://n02.test.com': createMockClient(98),
'https://n03.test.com': createMockClient(highest),
it('block diff is null if query fails indicating non operational', async () => {
const failedQuery: MockedResponse<StatisticsQuery> = {
request: {
query: StatisticsDocument,
},
result: {
// @ts-ignore failed query with no result
data: {},
},
};
const { result } = setup(clientCollection, CURRENT_URL);
await act(async () => {
jest.advanceTimersByTime(INTERVAL_TIME);
const { result } = setup(failedQuery, {
blockHeight: 1,
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 () => {
const clientCollection: ClientCollection = {
[CURRENT_URL]: createRejectingClient(),
'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);
it('returns 0 if no headers are found (waits until stats query resolves)', async () => {
const { result } = setup(createStatsMock(1), undefined);
expect(result.current.blockDiff).toEqual(null);
expect(result.current.coreBlockHeight).toEqual(undefined);
expect(result.current.datanodeBlockHeight).toEqual(undefined);
await waitFor(() => {
expect(result.current.blockDiff).toEqual(0);
expect(result.current.coreBlockHeight).toEqual(1);
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);
});
});

View File

@ -1,88 +1,47 @@
import compact from 'lodash/compact';
import shuffle from 'lodash/shuffle';
import type { createClient } from '@vegaprotocol/apollo-client';
import { useEffect, useState } from 'react';
import type { StatisticsQuery } from '../utils/__generated__/Node';
import { StatisticsDocument } from '../utils/__generated__/Node';
import type { ClientCollection } from './use-nodes';
import { useEffect, useMemo } from 'react';
import { useStatisticsQuery } from '../utils/__generated__/Node';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
import { useEnvironment } from './use-environment';
import { fromNanoSeconds } from '@vegaprotocol/react-helpers';
// How often to query other nodes
export const INTERVAL_TIME = 30 * 1000;
// Number of nodes to query against
export const NODE_SUBSET_COUNT = 5;
export const useNodeHealth = () => {
const url = useEnvironment((store) => store.VEGA_URL);
const headerStore = useHeaderStore();
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
// to calculate and return the difference between the most advanced block
// and the block height of the current node
export const useNodeHealth = (clients: ClientCollection, vegaUrl?: string) => {
const [blockDiff, setBlockDiff] = useState(0);
const blockDiff = useMemo(() => {
if (!data?.statistics.blockHeight) {
return null;
}
if (!headers) {
return 0;
}
return Number(data.statistics.blockHeight) - headers.blockHeight;
}, [data, headers]);
useEffect(() => {
if (!clients || !vegaUrl) return;
if (error) {
stopPolling();
}
}, [error, stopPolling]);
const fetchBlockHeight = async (
client?: ReturnType<typeof createClient>
) => {
try {
const result = await client?.query<StatisticsQuery>({
query: StatisticsDocument,
fetchPolicy: 'no-cache', // always fetch and never cache
});
if (!result) return null;
if (result.error) return null;
return result;
} 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);
return {
error,
loading,
coreBlockHeight: data?.statistics
? Number(data.statistics.blockHeight)
: undefined,
coreVegaTime: data?.statistics
? fromNanoSeconds(data?.statistics.vegaTime)
: undefined,
datanodeBlockHeight: headers?.blockHeight,
datanodeVegaTime: headers?.timestamp,
blockDiff,
};
};

View File

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

View File

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

View File

@ -1,44 +1,17 @@
import type z from 'zod';
import type { configSchema } from './utils/validate-configuration';
import type { envSchema } from './utils/validate-environment';
import { Networks, ENV_KEYS } from './utils/validate-environment';
export { ENV_KEYS, Networks };
export const CUSTOM_NODE_KEY = 'custom';
export enum ErrorType {
INVALID_URL,
SUBSCRIPTION_ERROR,
CONNECTION_ERROR,
CONNECTION_ERROR_ALL,
CONFIG_LOAD_ERROR,
CONFIG_VALIDATION_ERROR,
export enum Networks {
CUSTOM = 'CUSTOM',
SANDBOX = 'SANDBOX',
TESTNET = 'TESTNET',
STAGNET1 = 'STAGNET1',
STAGNET3 = 'STAGNET3',
DEVNET = 'DEVNET',
MAINNET = 'MAINNET',
MIRROR = 'MIRROR',
}
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 Environment = z.infer<typeof envSchema>;
export type Configuration = z.infer<typeof configSchema>;
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>;
};
export const CUSTOM_NODE_KEY = 'custom' as const;

View File

@ -2,6 +2,7 @@ query Statistics {
statistics {
chainId
blockHeight
vegaTime
}
}

View File

@ -6,7 +6,7 @@ const defaultOptions = {} as const;
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; }>;
@ -19,6 +19,7 @@ export const StatisticsDocument = gql`
statistics {
chainId
blockHeight
vegaTime
}
}
`;

View File

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

View File

@ -1,19 +1,32 @@
import type { ZodIssue } from 'zod';
import { ZodError } from 'zod';
/**
* Makes a nice error string to be printed to the console
*/
export const compileErrors = (
headline: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any,
compileIssue?: (issue: ZodIssue) => string
error: any
) => {
if (error instanceof ZodError) {
return error.issues.reduce((acc, issue) => {
return (
acc + `\n - ${compileIssue ? compileIssue(issue) : issue.message}`
);
}, `${headline}:`);
return acc + `\n - ${compileIssue(issue)}`;
}, headline);
}
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}`;
}
};

View File

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

View File

@ -10,6 +10,7 @@ export const statisticsQuery = (
__typename: 'Statistics',
chainId: 'chain-id',
blockHeight: '11',
vegaTime: new Date().toISOString(),
},
};

View File

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

View File

@ -1,19 +1,5 @@
import z from 'zod';
import type { Configuration } from '../types';
import { compileErrors } from './compile-errors';
export const configSchema = z.object({
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);
}
};

View File

@ -1,18 +1,5 @@
import type { ZodIssue } from 'zod';
import z from 'zod';
import type { Environment } 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',
}
import { Networks } from '../types';
const schemaObject = {
VEGA_URL: z.optional(z.string()),
@ -34,7 +21,7 @@ const schemaObject = {
[env]: z.optional(z.string()),
}),
{}
)
) as Record<Networks, z.ZodOptional<z.ZodString>>
)
.strict({
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()),
};
export const ENV_KEYS = Object.keys(schemaObject) as Array<
keyof typeof schemaObject
>;
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;
}
};
// combine schema above with custom rule to ensure either
// VEGA_URL or VEGA_CONFIG_URL are provided
export const envSchema = z.object(schemaObject).refine(
(data) => {
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.',
}
);
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
);
}
};

View File

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

View File

@ -1,7 +1,7 @@
import { Fragment } from 'react';
import { Fragment, useState } from 'react';
import { t } from '@vegaprotocol/react-helpers';
import { Link, Lozenge } from '@vegaprotocol/ui-toolkit';
import { useEnvironment } from '@vegaprotocol/environment';
import { NodeSwitcherDialog, useEnvironment } from '@vegaprotocol/environment';
const getFeedbackLinks = (gitOriginUrl?: string) =>
[
@ -18,59 +18,65 @@ export const NetworkInfo = () => {
GIT_ORIGIN_URL,
GITHUB_FEEDBACK_URL,
ETHEREUM_PROVIDER_URL,
setNodeSwitcherOpen,
} = useEnvironment();
const [nodeSwitcherOpen, setNodeSwitcherOpen] = useState(false);
const feedbackLinks = getFeedbackLinks(GITHUB_FEEDBACK_URL);
return (
<div data-testid="git-info">
<p data-testid="git-network-data" className="mb-2">
{t('Reading network data from')}{' '}
<Lozenge className="bg-neutral-300 dark:bg-neutral-700">
{VEGA_URL}
</Lozenge>
. <Link onClick={() => setNodeSwitcherOpen()}>{t('Edit')}</Link>
</p>
<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>
<>
<div data-testid="git-info">
<p data-testid="git-network-data" className="mb-2">
{t('Reading network data from')}{' '}
<Lozenge className="bg-neutral-300 dark:bg-neutral-700">
{VEGA_URL}
</Lozenge>
. <Link onClick={() => setNodeSwitcherOpen(true)}>{t('Edit')}</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 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>
)}
</div>
{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>
)}
{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}
/>
</>
);
};

View File

@ -9,6 +9,7 @@ export * from './get-nodes';
export * from './grid';
export * from './i18n';
export * from './is-asset-erc20';
export * from './is-valid-url';
export * from './links';
export * from './local-logger';
export * from './market-expires';

View File

@ -0,0 +1,9 @@
export const isValidUrl = (url?: string) => {
if (!url) return false;
try {
new URL(url);
return true;
} catch {
return false;
}
};

View File

@ -18,7 +18,7 @@ import {
ViewConnector,
WalletError,
} from '../connectors';
import { EnvironmentProvider } from '@vegaprotocol/environment';
import { useEnvironment } from '@vegaprotocol/environment';
import type { ChainIdQuery } from '@vegaprotocol/react-helpers';
import { ChainIdDocument } from '@vegaprotocol/react-helpers';
@ -30,6 +30,20 @@ const mockStoreObj: Partial<VegaWalletDialogStore> = {
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', () => ({
create: () => (storeGetter: (store: VegaWalletDialogStore) => unknown) =>
storeGetter(mockStoreObj as VegaWalletDialogStore),
@ -56,16 +70,6 @@ beforeEach(() => {
const mockVegaWalletUrl = 'http://mock.wallet.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';
@ -83,13 +87,11 @@ function generateJSX(props?: Partial<VegaConnectDialogProps>) {
},
};
return (
<EnvironmentProvider definitions={mockEnvironment}>
<MockedProvider mocks={[chainIdMock]}>
<VegaWalletProvider>
<VegaConnectDialog {...defaultProps} {...props} />
</VegaWalletProvider>
</MockedProvider>
</EnvironmentProvider>
<MockedProvider mocks={[chainIdMock]}>
<VegaWalletProvider>
<VegaConnectDialog {...defaultProps} {...props} />
</VegaWalletProvider>
</MockedProvider>
);
}

View File

@ -19,6 +19,5 @@ export * from './lib/web3-provider';
export * from './lib/web3-connectors';
export * from './lib/web3-connect-dialog';
export * from './lib/web3-connect-store';
export * from './lib/web3-container';
export * from './lib/url-connector';
export * from './lib/eip-1193-custom-bridge';

View File

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

View File

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