feat(trading): datanode block header for env and node switcher (#2905)
This commit is contained in:
parent
64c92ce91d
commit
a82509f0e0
@ -1,5 +1,5 @@
|
||||
import classnames from 'classnames';
|
||||
import { 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;
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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',
|
||||
|
30
apps/trading/components/app-loader/app-failure.tsx
Normal file
30
apps/trading/components/app-loader/app-failure.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
import { Button } from '@vegaprotocol/ui-toolkit';
|
||||
import { useGlobalStore } from '../../stores';
|
||||
|
||||
export const AppFailure = ({
|
||||
title,
|
||||
error,
|
||||
}: {
|
||||
title: string;
|
||||
error?: string | null;
|
||||
}) => {
|
||||
const { setNodeSwitcher } = useGlobalStore((store) => ({
|
||||
nodeSwitcherOpen: store.nodeSwitcherDialog,
|
||||
setNodeSwitcher: (open: boolean) =>
|
||||
store.update({ nodeSwitcherDialog: open }),
|
||||
}));
|
||||
const nonIdealWrapperClasses =
|
||||
'h-full min-h-screen flex items-center justify-center';
|
||||
return (
|
||||
<div className={nonIdealWrapperClasses}>
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl mb-4">{title}</h1>
|
||||
{error && <p className="text-sm mb-8">{error}</p>}
|
||||
<Button onClick={() => setNodeSwitcher(true)}>
|
||||
{t('Change node')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
96
apps/trading/components/app-loader/app-loader.tsx
Normal file
96
apps/trading/components/app-loader/app-loader.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import type { InMemoryCacheConfig } from '@apollo/client';
|
||||
import {
|
||||
NetworkLoader,
|
||||
NodeGuard,
|
||||
useEnvironment,
|
||||
} from '@vegaprotocol/environment';
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
import { MaintenancePage } from '@vegaprotocol/ui-toolkit';
|
||||
import { VegaWalletProvider } from '@vegaprotocol/wallet';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { ReactNode } from 'react';
|
||||
import { AppFailure } from './app-failure';
|
||||
import { Web3Provider } from './web3-provider';
|
||||
|
||||
const DynamicLoader = dynamic(() => import('../preloader/preloader'), {
|
||||
loading: () => <>Loading...</>,
|
||||
});
|
||||
|
||||
export const AppLoader = ({ children }: { children: ReactNode }) => {
|
||||
const { error, VEGA_URL, MAINTENANCE_PAGE } = useEnvironment((store) => ({
|
||||
error: store.error,
|
||||
VEGA_URL: store.VEGA_URL,
|
||||
MAINTENANCE_PAGE: store.MAINTENANCE_PAGE,
|
||||
}));
|
||||
|
||||
if (MAINTENANCE_PAGE) {
|
||||
return <MaintenancePage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<NetworkLoader
|
||||
cache={cacheConfig}
|
||||
skeleton={<DynamicLoader />}
|
||||
failure={
|
||||
<AppFailure title={t('Could not initialize app')} error={error} />
|
||||
}
|
||||
>
|
||||
<NodeGuard
|
||||
skeleton={<DynamicLoader />}
|
||||
failure={<AppFailure title={t(`Node: ${VEGA_URL} is unsuitable`)} />}
|
||||
>
|
||||
<Web3Provider>
|
||||
<VegaWalletProvider>{children}</VegaWalletProvider>
|
||||
</Web3Provider>
|
||||
</NodeGuard>
|
||||
</NetworkLoader>
|
||||
);
|
||||
};
|
||||
|
||||
const cacheConfig: InMemoryCacheConfig = {
|
||||
typePolicies: {
|
||||
Account: {
|
||||
keyFields: false,
|
||||
fields: {
|
||||
balanceFormatted: {},
|
||||
},
|
||||
},
|
||||
Instrument: {
|
||||
keyFields: false,
|
||||
},
|
||||
TradableInstrument: {
|
||||
keyFields: ['instrument'],
|
||||
},
|
||||
Product: {
|
||||
keyFields: ['settlementAsset', ['id']],
|
||||
},
|
||||
MarketData: {
|
||||
keyFields: ['market', ['id']],
|
||||
},
|
||||
Node: {
|
||||
keyFields: false,
|
||||
},
|
||||
Withdrawal: {
|
||||
fields: {
|
||||
pendingOnForeignChain: {
|
||||
read: (isPending = false) => isPending,
|
||||
},
|
||||
},
|
||||
},
|
||||
ERC20: {
|
||||
keyFields: ['contractAddress'],
|
||||
},
|
||||
PositionUpdate: {
|
||||
keyFields: false,
|
||||
},
|
||||
AccountUpdate: {
|
||||
keyFields: false,
|
||||
},
|
||||
Party: {
|
||||
keyFields: false,
|
||||
},
|
||||
Fees: {
|
||||
keyFields: false,
|
||||
},
|
||||
},
|
||||
};
|
@ -1,121 +1,3 @@
|
||||
import type { ReactNode } from 'react';
|
||||
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';
|
||||
|
58
apps/trading/components/app-loader/web3-provider.tsx
Normal file
58
apps/trading/components/app-loader/web3-provider.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import {
|
||||
useEthereumConfig,
|
||||
createConnectors,
|
||||
Web3Provider as Web3ProviderInternal,
|
||||
useWeb3ConnectStore,
|
||||
} from '@vegaprotocol/web3';
|
||||
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
import { useEnvironment } from '@vegaprotocol/environment';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const Web3Provider = ({ children }: { children: ReactNode }) => {
|
||||
const { config, loading, error } = useEthereumConfig();
|
||||
const { ETHEREUM_PROVIDER_URL, ETH_LOCAL_PROVIDER_URL, ETH_WALLET_MNEMONIC } =
|
||||
useEnvironment();
|
||||
const [connectors, initializeConnectors] = useWeb3ConnectStore((store) => [
|
||||
store.connectors,
|
||||
store.initialize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.chain_id) {
|
||||
return initializeConnectors(
|
||||
createConnectors(
|
||||
ETHEREUM_PROVIDER_URL,
|
||||
Number(config?.chain_id),
|
||||
ETH_LOCAL_PROVIDER_URL,
|
||||
ETH_WALLET_MNEMONIC
|
||||
),
|
||||
Number(config.chain_id)
|
||||
);
|
||||
}
|
||||
}, [
|
||||
config?.chain_id,
|
||||
ETHEREUM_PROVIDER_URL,
|
||||
initializeConnectors,
|
||||
ETH_LOCAL_PROVIDER_URL,
|
||||
ETH_WALLET_MNEMONIC,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AsyncRenderer
|
||||
loading={loading}
|
||||
error={error}
|
||||
data={connectors}
|
||||
noDataCondition={(d) => {
|
||||
if (!d) return true;
|
||||
return d.length < 1;
|
||||
}}
|
||||
noDataMessage={t('Could not fetch Ethereum configuration')}
|
||||
>
|
||||
<Web3ProviderInternal connectors={connectors}>
|
||||
<>{children}</>
|
||||
</Web3ProviderInternal>
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
@ -1,57 +1,38 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { 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();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from './lib/apollo-client';
|
||||
export * from './cache-config';
|
||||
export * from './lib/header-store';
|
||||
|
@ -14,6 +14,7 @@ import { onError } from '@apollo/client/link/error';
|
||||
import { RetryLink } from '@apollo/client/link/retry';
|
||||
import 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,
|
||||
});
|
||||
|
12
libs/apollo-client/src/lib/header-store.ts
Normal file
12
libs/apollo-client/src/lib/header-store.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface HeaderEntry {
|
||||
blockHeight: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
type HeaderStore = {
|
||||
[url: string]: HeaderEntry | undefined;
|
||||
};
|
||||
|
||||
export const useHeaderStore = create<HeaderStore>(() => ({}));
|
18
libs/environment/__mocks__/zustand.js
Normal file
18
libs/environment/__mocks__/zustand.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { act } from 'react-dom/test-utils';
|
||||
const zu = jest.requireActual('zustand'); // if using jest
|
||||
|
||||
// a variable to hold reset functions for all stores declared in the app
|
||||
const storeResetFns = new Set();
|
||||
|
||||
// when creating a store, we get its initial state, create a reset function and add it in the set
|
||||
export const create = (createState) => {
|
||||
const store = zu.create(createState);
|
||||
const initialState = store.getState();
|
||||
storeResetFns.add(() => store.setState(initialState, true));
|
||||
return store;
|
||||
};
|
||||
|
||||
// Reset all stores after each test run
|
||||
beforeEach(() => {
|
||||
act(() => storeResetFns.forEach((resetFn) => resetFn()));
|
||||
});
|
@ -1,4 +1,4 @@
|
||||
export * from './network-loader';
|
||||
export * from './network-switcher';
|
||||
export * from './node-guard';
|
||||
export * from './node-switcher';
|
||||
export * from './node-switcher-dialog';
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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>;
|
||||
|
1
libs/environment/src/components/node-guard/index.ts
Normal file
1
libs/environment/src/components/node-guard/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './node-guard';
|
26
libs/environment/src/components/node-guard/node-guard.tsx
Normal file
26
libs/environment/src/components/node-guard/node-guard.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useStatisticsQuery } from '../../utils/__generated__/Node';
|
||||
|
||||
export const NodeGuard = ({
|
||||
children,
|
||||
failure,
|
||||
skeleton,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
failure: ReactNode;
|
||||
skeleton: ReactNode;
|
||||
}) => {
|
||||
const { error, loading } = useStatisticsQuery();
|
||||
const wrapperClasses = 'h-full min-h-screen flex items-center justify-center';
|
||||
|
||||
if (loading) {
|
||||
return <div className={wrapperClasses}>{skeleton}</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className={wrapperClasses}>{failure}</div>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
return <>{children}</>;
|
||||
};
|
@ -1 +0,0 @@
|
||||
export * from './node-switcher-dialog';
|
@ -1,59 +0,0 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { Dialog, Loader } from '@vegaprotocol/ui-toolkit';
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
import { NodeSwitcher } from '../node-switcher';
|
||||
import { useEnvironment } from '../../hooks/use-environment';
|
||||
import type { Configuration } from '../../types';
|
||||
|
||||
type NodeSwitcherDialogProps = Pick<
|
||||
ComponentProps<typeof NodeSwitcher>,
|
||||
'initialErrorType' | 'onConnect'
|
||||
> & {
|
||||
loading: boolean;
|
||||
config?: Configuration;
|
||||
dialogOpen: boolean;
|
||||
setDialogOpen: (dialogOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const NodeSwitcherDialog = ({
|
||||
config,
|
||||
loading,
|
||||
initialErrorType,
|
||||
dialogOpen,
|
||||
setDialogOpen,
|
||||
onConnect,
|
||||
}: NodeSwitcherDialogProps) => {
|
||||
const { VEGA_ENV } = useEnvironment();
|
||||
return (
|
||||
<Dialog open={dialogOpen} onChange={setDialogOpen} size="medium">
|
||||
<div className="uppercase text-xl text-center mb-2">
|
||||
{t('Connected node')}
|
||||
</div>
|
||||
{!config && loading && (
|
||||
<div className="py-8">
|
||||
<p className="mb-4 text-center">{t('Loading configuration...')}</p>
|
||||
<Loader size="large" />
|
||||
</div>
|
||||
)}
|
||||
{config && dialogOpen && (
|
||||
<>
|
||||
<p className="mb-2 text-center">
|
||||
{t(`This app will only work on a `)}
|
||||
<span className="font-mono capitalize">
|
||||
{VEGA_ENV.toLowerCase()}
|
||||
</span>
|
||||
{t(' chain ID')}
|
||||
</p>
|
||||
<NodeSwitcher
|
||||
config={config}
|
||||
initialErrorType={initialErrorType}
|
||||
onConnect={(url) => {
|
||||
onConnect(url);
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -0,0 +1,25 @@
|
||||
import { ApolloProvider } from '@apollo/client';
|
||||
import { createClient } from '@vegaprotocol/apollo-client';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const ApolloWrapper = ({
|
||||
url,
|
||||
children,
|
||||
}: {
|
||||
url: string;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const client = useMemo(
|
||||
() =>
|
||||
createClient({
|
||||
url,
|
||||
cacheConfig: undefined,
|
||||
retry: false,
|
||||
connectToDevTools: false,
|
||||
connectToHeaderStore: true,
|
||||
}),
|
||||
[url]
|
||||
);
|
||||
return <ApolloProvider client={client}>{children}</ApolloProvider>;
|
||||
};
|
@ -1 +1,2 @@
|
||||
export * from './node-switcher-dialog';
|
||||
export * from './node-switcher';
|
||||
|
@ -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>
|
||||
|
@ -1,34 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useStatisticsQuery } from '../../utils/__generated__/Node';
|
||||
|
||||
type NodeBlockHeightProps = {
|
||||
value?: number;
|
||||
setValue: (value: number) => void;
|
||||
};
|
||||
|
||||
const POLL_INTERVAL = 3000;
|
||||
|
||||
export const NodeBlockHeight = ({ value, setValue }: NodeBlockHeightProps) => {
|
||||
const { data, startPolling, stopPolling } = useStatisticsQuery({
|
||||
pollInterval: POLL_INTERVAL,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleStartPoll = () => startPolling(POLL_INTERVAL);
|
||||
const handleStopPoll = () => stopPolling();
|
||||
window.addEventListener('blur', handleStopPoll);
|
||||
window.addEventListener('focus', handleStartPoll);
|
||||
return () => {
|
||||
window.removeEventListener('blur', handleStopPoll);
|
||||
window.removeEventListener('focus', handleStartPoll);
|
||||
};
|
||||
}, [startPolling, stopPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.statistics?.blockHeight) {
|
||||
setValue(Number(data.statistics.blockHeight));
|
||||
}
|
||||
}, [setValue, data?.statistics?.blockHeight]);
|
||||
|
||||
return <span>{value ?? '-'}</span>;
|
||||
};
|
@ -1,137 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ApolloProvider } from '@apollo/client';
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
import type { NodeData } from '../../types';
|
||||
import { LayoutRow } from './layout-row';
|
||||
import { LayoutCell } from './layout-cell';
|
||||
import { NodeBlockHeight } from './node-block-height';
|
||||
import type { createClient } from '@vegaprotocol/apollo-client';
|
||||
|
||||
type NodeStatsContentProps = {
|
||||
data?: NodeData;
|
||||
highestBlock: number;
|
||||
setBlock: (value: number) => void;
|
||||
children?: ReactNode;
|
||||
dataTestId?: string;
|
||||
};
|
||||
|
||||
const getResponseTimeDisplayValue = (
|
||||
responseTime?: NodeData['responseTime']
|
||||
) => {
|
||||
if (typeof responseTime?.value === 'number') {
|
||||
return `${Number(responseTime.value).toFixed(2)}ms`;
|
||||
}
|
||||
if (responseTime?.hasError) {
|
||||
return t('n/a');
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
const getBlockDisplayValue = (
|
||||
block: NodeData['block'] | undefined,
|
||||
setBlock: (block: number) => void
|
||||
) => {
|
||||
if (block?.value) {
|
||||
return <NodeBlockHeight value={block?.value} setValue={setBlock} />;
|
||||
}
|
||||
if (block?.hasError) {
|
||||
return t('n/a');
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
const getSubscriptionDisplayValue = (
|
||||
subscription?: NodeData['subscription']
|
||||
) => {
|
||||
if (subscription?.value) {
|
||||
return t('Yes');
|
||||
}
|
||||
if (subscription?.hasError) {
|
||||
return t('No');
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
const NodeStatsContent = ({
|
||||
// @ts-ignore Allow defaulting to an empty object
|
||||
data = {},
|
||||
highestBlock,
|
||||
setBlock,
|
||||
children,
|
||||
dataTestId,
|
||||
}: NodeStatsContentProps) => {
|
||||
return (
|
||||
<LayoutRow dataTestId={dataTestId}>
|
||||
{children}
|
||||
<LayoutCell
|
||||
label={t('Response time')}
|
||||
isLoading={data.responseTime?.isLoading}
|
||||
hasError={data.responseTime?.hasError}
|
||||
dataTestId="response-time-cell"
|
||||
>
|
||||
{getResponseTimeDisplayValue(data.responseTime)}
|
||||
</LayoutCell>
|
||||
<LayoutCell
|
||||
label={t('Block')}
|
||||
isLoading={data.block?.isLoading}
|
||||
hasError={
|
||||
data.block?.hasError ||
|
||||
(!!data.block?.value && highestBlock > data.block.value)
|
||||
}
|
||||
dataTestId="block-cell"
|
||||
>
|
||||
{getBlockDisplayValue(data.block, setBlock)}
|
||||
</LayoutCell>
|
||||
<LayoutCell
|
||||
label={t('Subscription')}
|
||||
isLoading={data.subscription?.isLoading}
|
||||
hasError={data.subscription?.hasError}
|
||||
dataTestId="subscription-cell"
|
||||
>
|
||||
{getSubscriptionDisplayValue(data.subscription)}
|
||||
</LayoutCell>
|
||||
</LayoutRow>
|
||||
);
|
||||
};
|
||||
|
||||
type WrapperProps = {
|
||||
client?: ReturnType<typeof createClient>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const Wrapper = ({ client, children }: WrapperProps) => {
|
||||
if (client) {
|
||||
return <ApolloProvider client={client}>{children}</ApolloProvider>;
|
||||
}
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export type NodeStatsProps = {
|
||||
data?: NodeData;
|
||||
client?: ReturnType<typeof createClient>;
|
||||
highestBlock: number;
|
||||
setBlock: (value: number) => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const NodeStats = ({
|
||||
data,
|
||||
client,
|
||||
highestBlock,
|
||||
children,
|
||||
setBlock,
|
||||
}: NodeStatsProps) => {
|
||||
return (
|
||||
<Wrapper client={client}>
|
||||
<NodeStatsContent
|
||||
data={data}
|
||||
highestBlock={highestBlock}
|
||||
setBlock={setBlock}
|
||||
dataTestId="node-row"
|
||||
>
|
||||
{children}
|
||||
</NodeStatsContent>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
@ -0,0 +1,16 @@
|
||||
import { Dialog } from '@vegaprotocol/ui-toolkit';
|
||||
import { NodeSwitcher } from './node-switcher';
|
||||
|
||||
export const NodeSwitcherDialog = ({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (x: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onChange={setOpen} size="medium">
|
||||
<NodeSwitcher closeDialog={() => setOpen(false)} />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -1,584 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { MockedProvider } from '@apollo/react-testing';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useEnvironment } from '../../hooks/use-environment';
|
||||
import { useNodes } from '../../hooks/use-nodes';
|
||||
import createMockClient from '../../hooks/mocks/apollo-client';
|
||||
import { StatisticsDocument } from '../../utils/__generated__/Node';
|
||||
import { NodeSwitcher } from './node-switcher';
|
||||
import { getErrorByType } from '../../utils/validate-node';
|
||||
import type { Configuration, NodeData } from '../../types';
|
||||
import { Networks, ErrorType, CUSTOM_NODE_KEY } from '../../types';
|
||||
|
||||
type NodeDataProp = 'responseTime' | 'block' | 'chain' | 'subscription';
|
||||
|
||||
jest.mock('../../hooks/use-environment');
|
||||
jest.mock('../../hooks/use-nodes');
|
||||
|
||||
const mockNodesImplementation =
|
||||
(
|
||||
updateNodeUrlMock: jest.Mock,
|
||||
getNodeState: typeof getValidNodeState = getValidNodeState
|
||||
) =>
|
||||
(config: Configuration) => {
|
||||
const [{ state, clients }, setImplementation] = useState<{
|
||||
state: Record<string, NodeData>;
|
||||
clients: Record<string, ReturnType<typeof createMockClients>>;
|
||||
}>({
|
||||
state: createMockState(Networks.TESTNET, config.hosts),
|
||||
clients: createMockClients(config.hosts),
|
||||
});
|
||||
|
||||
return {
|
||||
state,
|
||||
clients,
|
||||
updateNodeUrl: updateNodeUrlMock.mockImplementation(
|
||||
(node: string, url: string) => {
|
||||
setImplementation((prev) => ({
|
||||
state: {
|
||||
...prev.state,
|
||||
[node]: getNodeState(Networks.TESTNET, url),
|
||||
},
|
||||
clients: {
|
||||
...prev.clients,
|
||||
[node]: createMockClient({ network: Networks.TESTNET }),
|
||||
},
|
||||
}));
|
||||
}
|
||||
),
|
||||
updateNodeBlock: (node: string, value: number) => {
|
||||
setImplementation((prev) => ({
|
||||
state: {
|
||||
...prev.state,
|
||||
[node]: {
|
||||
...prev.state[node],
|
||||
block: {
|
||||
...prev.state[node].block,
|
||||
value,
|
||||
},
|
||||
},
|
||||
},
|
||||
clients: prev.clients,
|
||||
}));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const statsQueryMock = {
|
||||
request: {
|
||||
query: StatisticsDocument,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
statistics: {
|
||||
blockHeight: 1234,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const onConnect = jest.fn();
|
||||
|
||||
const HOSTS = ['https://host1.com', 'https://host2.com'];
|
||||
|
||||
const enum STATES {
|
||||
LOADING = 'is loading',
|
||||
HAS_ERROR = 'has an error',
|
||||
}
|
||||
|
||||
const getValidNodeState = (env: Networks, url: string) => ({
|
||||
url,
|
||||
initialized: true,
|
||||
responseTime: {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: 10,
|
||||
},
|
||||
block: {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: 123,
|
||||
},
|
||||
subscription: {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: true,
|
||||
},
|
||||
chain: {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: `${env.toLowerCase()}-1234`,
|
||||
},
|
||||
});
|
||||
|
||||
const createMockState = (env: Networks, nodes: string[]) =>
|
||||
nodes.reduce(
|
||||
(acc, node) => ({
|
||||
...acc,
|
||||
[node]: getValidNodeState(env, node),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
const createMockClients = (nodes: string[]) =>
|
||||
nodes.reduce(
|
||||
(acc, node) => ({
|
||||
...acc,
|
||||
[node]: createMockClient({ network: Networks.TESTNET }),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
onConnect.mockReset();
|
||||
|
||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||
useEnvironment.mockImplementation(() => ({
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
VEGA_URL: undefined,
|
||||
}));
|
||||
|
||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||
useNodes.mockImplementation((config: Configuration) => ({
|
||||
state: createMockState(Networks.TESTNET, config.hosts),
|
||||
clients: createMockClients(config.hosts),
|
||||
updateNodeUrl: jest.fn(),
|
||||
updateNodeBlock: jest.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Node switcher', () => {
|
||||
it('renders with empty config', () => {
|
||||
render(<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />);
|
||||
|
||||
expect(() => screen.getAllByTestId('node')).toThrow();
|
||||
expect(screen.getByRole('radio', { checked: false })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders with the provided config nodes', () => {
|
||||
render(<NodeSwitcher config={{ hosts: HOSTS }} onConnect={onConnect} />);
|
||||
|
||||
HOSTS.forEach((host) => {
|
||||
expect(
|
||||
screen.getByRole('radio', { checked: false, name: host })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('radio', { checked: false, name: 'Other' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the node in the environment as selected', () => {
|
||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||
useEnvironment.mockImplementation(() => ({
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
VEGA_URL: HOSTS[0],
|
||||
}));
|
||||
render(<NodeSwitcher config={{ hosts: HOSTS }} onConnect={onConnect} />);
|
||||
|
||||
HOSTS.forEach((host) => {
|
||||
expect(
|
||||
screen.getByRole('radio', { checked: host === HOSTS[0], name: host })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('radio', { checked: false, name: 'Other' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).not.toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
});
|
||||
|
||||
it.each`
|
||||
dataProp | state
|
||||
${'responseTime'} | ${STATES.LOADING}
|
||||
${'responseTime'} | ${STATES.HAS_ERROR}
|
||||
${'block'} | ${STATES.LOADING}
|
||||
${'block'} | ${STATES.HAS_ERROR}
|
||||
${'chain'} | ${STATES.LOADING}
|
||||
${'chain'} | ${STATES.HAS_ERROR}
|
||||
${'subscription'} | ${STATES.LOADING}
|
||||
${'subscription'} | ${STATES.HAS_ERROR}
|
||||
`(
|
||||
'disables selecting a node when the $dataProp $state',
|
||||
({ dataProp, state }: { dataProp: NodeDataProp; state: STATES }) => {
|
||||
const mockUrl = 'https://host.url';
|
||||
const mockConfig = {
|
||||
hosts: [mockUrl],
|
||||
};
|
||||
|
||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||
useNodes.mockImplementation((config: Configuration) => {
|
||||
const nodeState = getValidNodeState(Networks.TESTNET, mockUrl);
|
||||
return {
|
||||
state: {
|
||||
[mockUrl]: {
|
||||
...nodeState,
|
||||
[dataProp]: {
|
||||
...nodeState[dataProp],
|
||||
isLoading:
|
||||
state === STATES.LOADING
|
||||
? true
|
||||
: nodeState[dataProp].isLoading,
|
||||
hasError:
|
||||
state === STATES.HAS_ERROR
|
||||
? true
|
||||
: nodeState[dataProp].hasError,
|
||||
value: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
clients: createMockClients(config.hosts),
|
||||
updateNodeUrl: jest.fn(),
|
||||
updateNodeBlock: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[statsQueryMock]}>
|
||||
<NodeSwitcher config={mockConfig} onConnect={onConnect} />
|
||||
</MockedProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radio', { name: mockUrl })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('disables selecting a node when it has an invalid url', () => {
|
||||
const mockUrl = 'not-valid-url';
|
||||
const mockConfig = {
|
||||
hosts: [mockUrl],
|
||||
};
|
||||
|
||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||
useNodes.mockImplementation((config: Configuration) => ({
|
||||
state: {
|
||||
[mockUrl]: getValidNodeState(Networks.TESTNET, mockUrl),
|
||||
},
|
||||
clients: createMockClients(config.hosts),
|
||||
updateNodeUrl: jest.fn(),
|
||||
updateNodeBlock: jest.fn(),
|
||||
}));
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[statsQueryMock]}>
|
||||
<NodeSwitcher config={mockConfig} onConnect={onConnect} />
|
||||
</MockedProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radio', { name: mockUrl })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
});
|
||||
|
||||
it('allows connecting to a valid node', () => {
|
||||
render(<NodeSwitcher config={{ hosts: HOSTS }} onConnect={onConnect} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
fireEvent.click(screen.getByRole('radio', { name: HOSTS[0] }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).not.toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
|
||||
expect(onConnect).toHaveBeenCalledWith(HOSTS[0]);
|
||||
});
|
||||
|
||||
it('allows checking a custom node', () => {
|
||||
const updateNodeUrlMock = jest.fn();
|
||||
|
||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||
useNodes.mockImplementation((config: Configuration) => ({
|
||||
state: createMockState(Networks.TESTNET, config.hosts),
|
||||
clients: createMockClients(config.hosts),
|
||||
updateNodeUrl: updateNodeUrlMock,
|
||||
updateNodeBlock: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUrl = 'https://custom.url';
|
||||
render(
|
||||
<MockedProvider mocks={[statsQueryMock]}>
|
||||
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
|
||||
</MockedProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
|
||||
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true'
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: {
|
||||
value: mockUrl,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'false'
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Check' }));
|
||||
|
||||
expect(updateNodeUrlMock).toHaveBeenCalledWith(CUSTOM_NODE_KEY, mockUrl);
|
||||
});
|
||||
|
||||
it('allows connecting to a custom node', () => {
|
||||
const mockUrl = 'https://custom.url';
|
||||
const updateNodeUrlMock = jest.fn();
|
||||
|
||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||
useNodes.mockImplementation(mockNodesImplementation(updateNodeUrlMock));
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[statsQueryMock]}>
|
||||
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
|
||||
</MockedProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
|
||||
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true'
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: {
|
||||
value: mockUrl,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'false'
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Check' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).not.toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
|
||||
expect(onConnect).toHaveBeenCalledWith(mockUrl);
|
||||
});
|
||||
|
||||
it.each`
|
||||
dataProp | state
|
||||
${'responseTime'} | ${STATES.LOADING}
|
||||
${'responseTime'} | ${STATES.HAS_ERROR}
|
||||
${'block'} | ${STATES.LOADING}
|
||||
${'block'} | ${STATES.HAS_ERROR}
|
||||
${'chain'} | ${STATES.LOADING}
|
||||
${'chain'} | ${STATES.HAS_ERROR}
|
||||
${'subscription'} | ${STATES.LOADING}
|
||||
${'subscription'} | ${STATES.HAS_ERROR}
|
||||
`(
|
||||
'disables selecting a custom node when the $dataProp $state',
|
||||
({ dataProp, state }: { dataProp: NodeDataProp; state: STATES }) => {
|
||||
const mockUrl = 'https://custom.url';
|
||||
const updateNodeUrlMock = jest.fn();
|
||||
|
||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||
useNodes.mockImplementation(
|
||||
mockNodesImplementation(updateNodeUrlMock, (env) => {
|
||||
const nodeState = getValidNodeState(env, mockUrl);
|
||||
return {
|
||||
...nodeState,
|
||||
[dataProp]: {
|
||||
...nodeState[dataProp],
|
||||
isLoading:
|
||||
state === STATES.LOADING ? true : nodeState[dataProp].isLoading,
|
||||
hasError:
|
||||
state === STATES.HAS_ERROR
|
||||
? true
|
||||
: nodeState[dataProp].hasError,
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[statsQueryMock]}>
|
||||
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
|
||||
</MockedProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
|
||||
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true'
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: {
|
||||
value: mockUrl,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'false'
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Check' }));
|
||||
|
||||
if (state === STATES.LOADING) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(screen.getByRole('link', { name: 'Checking' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true'
|
||||
);
|
||||
}
|
||||
|
||||
if (state === STATES.HAS_ERROR) {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(screen.getByRole('link', { name: 'Check' })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'false'
|
||||
);
|
||||
}
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
|
||||
if (state === STATES.HAS_ERROR) {
|
||||
const expectedErrorType =
|
||||
dataProp === 'subscription'
|
||||
? ErrorType.SUBSCRIPTION_ERROR
|
||||
: ErrorType.CONNECTION_ERROR;
|
||||
const error = getErrorByType(
|
||||
expectedErrorType,
|
||||
Networks.TESTNET,
|
||||
mockUrl
|
||||
);
|
||||
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(error?.headline).not.toBeNull();
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(screen.getByText(error?.headline ?? '')).toBeInTheDocument();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('disables selecting a custom node when it has an invalid url', () => {
|
||||
const mockUrl = 'not-valid-url';
|
||||
const updateNodeUrlMock = jest.fn();
|
||||
|
||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||
useNodes.mockImplementation(mockNodesImplementation(updateNodeUrlMock));
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[statsQueryMock]}>
|
||||
<NodeSwitcher config={{ hosts: [] }} onConnect={onConnect} />
|
||||
</MockedProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: {
|
||||
value: mockUrl,
|
||||
},
|
||||
});
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Check' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Connect' })).toHaveAttribute(
|
||||
'disabled'
|
||||
);
|
||||
|
||||
const error = getErrorByType(
|
||||
ErrorType.INVALID_URL,
|
||||
Networks.TESTNET,
|
||||
mockUrl
|
||||
);
|
||||
|
||||
expect(error?.headline).not.toBeNull();
|
||||
expect(screen.getByText(error?.headline ?? '')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each`
|
||||
description | errorType
|
||||
${'the node has an invalid url'} | ${ErrorType.INVALID_URL}
|
||||
${'the node has a subscription issue'} | ${ErrorType.SUBSCRIPTION_ERROR}
|
||||
${'the node cannot be reached'} | ${ErrorType.CONNECTION_ERROR}
|
||||
${'none of the config nodes can be connected to'} | ${ErrorType.CONNECTION_ERROR_ALL}
|
||||
${'the config cannot be loaded'} | ${ErrorType.CONFIG_LOAD_ERROR}
|
||||
${'the config is invalid'} | ${ErrorType.CONFIG_VALIDATION_ERROR}
|
||||
`(
|
||||
'displays initial error when $description',
|
||||
({ errorType }: { errorType: ErrorType }) => {
|
||||
const mockEnvUrl = 'https://mock.url';
|
||||
|
||||
// @ts-ignore Typescript doesn't recognise mocked instances
|
||||
useEnvironment.mockImplementation(() => ({
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
VEGA_URL: mockEnvUrl,
|
||||
}));
|
||||
|
||||
render(
|
||||
<NodeSwitcher
|
||||
config={{ hosts: HOSTS }}
|
||||
initialErrorType={errorType}
|
||||
onConnect={onConnect}
|
||||
/>
|
||||
);
|
||||
|
||||
const error = getErrorByType(errorType, Networks.TESTNET, mockEnvUrl);
|
||||
|
||||
expect(error?.headline).not.toBeNull();
|
||||
expect(error?.message).not.toBeNull();
|
||||
expect(screen.getByText(error?.headline ?? '')).toBeInTheDocument();
|
||||
expect(screen.getByText(error?.message ?? '')).toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
});
|
@ -1,197 +1,237 @@
|
||||
import { useState } from 'react';
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,174 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { NodeSwitcher } from './node-switcher';
|
||||
import type { EnvStore } from '../../hooks';
|
||||
import { useEnvironment } from '../../hooks';
|
||||
import { Networks } from '../../types';
|
||||
import { MockedProvider } from '@apollo/react-testing';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
jest.mock('../../hooks/use-environment');
|
||||
jest.mock('./apollo-wrapper', () => ({
|
||||
ApolloWrapper: jest.fn(({ children }: { children: ReactNode }) => (
|
||||
<MockedProvider>{children}</MockedProvider>
|
||||
)),
|
||||
}));
|
||||
|
||||
global.performance.getEntriesByName = jest.fn().mockReturnValue([]);
|
||||
|
||||
const mockEnv = (env: Partial<EnvStore>) => {
|
||||
(useEnvironment as unknown as jest.Mock).mockImplementation(() => env);
|
||||
};
|
||||
|
||||
describe('NodeSwitcher', () => {
|
||||
it('renders with no nodes', () => {
|
||||
mockEnv({
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
nodes: [],
|
||||
});
|
||||
render(<NodeSwitcher closeDialog={jest.fn()} />);
|
||||
expect(
|
||||
screen.getByText(new RegExp(Networks.TESTNET, 'i'))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('node')).toHaveLength(0);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Connect to this node' })
|
||||
).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('renders with nodes', async () => {
|
||||
const nodes = [
|
||||
'https://n00.api.vega.xyz',
|
||||
'https://n01.api.vega.xyz',
|
||||
'https://n02.api.vega.xyz',
|
||||
];
|
||||
mockEnv({
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
nodes,
|
||||
});
|
||||
render(<NodeSwitcher closeDialog={jest.fn()} />);
|
||||
nodes.forEach((node) => {
|
||||
expect(
|
||||
screen.getByRole('radio', { checked: false, name: node })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('radio', { checked: false, name: 'Other' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Connect to this node' })
|
||||
).toHaveAttribute('disabled');
|
||||
|
||||
const rows = screen.getAllByTestId('node-row');
|
||||
expect(rows).toHaveLength(nodes.length);
|
||||
rows.forEach((r) => {
|
||||
const row = within(r);
|
||||
expect(row.getByTestId('response-time-cell')).toHaveTextContent(
|
||||
'Checking'
|
||||
);
|
||||
expect(row.getByTestId('block-height-cell')).toHaveTextContent(
|
||||
'Checking'
|
||||
);
|
||||
});
|
||||
|
||||
// Note actual requests tested in
|
||||
});
|
||||
|
||||
it('marks current node as selected', () => {
|
||||
const nodes = [
|
||||
'https://n00.api.vega.xyz',
|
||||
'https://n01.api.vega.xyz',
|
||||
'https://n02.api.vega.xyz',
|
||||
];
|
||||
const selectedNode = nodes[0];
|
||||
mockEnv({
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
VEGA_URL: selectedNode,
|
||||
nodes,
|
||||
});
|
||||
render(<NodeSwitcher closeDialog={jest.fn()} />);
|
||||
|
||||
nodes.forEach((node) => {
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
checked: node === selectedNode,
|
||||
name: node,
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('radio', { checked: false, name: 'Other' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Connect to this node' })
|
||||
).toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('allows setting a custom node', () => {
|
||||
const mockSetUrl = jest.fn();
|
||||
const mockUrl = 'https://custom.url';
|
||||
const nodes = ['https://n00.api.vega.xyz'];
|
||||
mockEnv({
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
nodes,
|
||||
setUrl: mockSetUrl,
|
||||
});
|
||||
render(<NodeSwitcher closeDialog={jest.fn()} />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Connect to this node' })
|
||||
).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: {
|
||||
value: mockUrl,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue(mockUrl);
|
||||
expect(screen.getByRole('button', { name: 'Check' })).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check' }));
|
||||
|
||||
const customRow = within(screen.getByTestId('custom-row'));
|
||||
expect(customRow.getByTestId('block-height-cell')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Connect to this node' })
|
||||
);
|
||||
expect(mockSetUrl).toHaveBeenCalledWith(mockUrl);
|
||||
});
|
||||
|
||||
it('disables a custom node with an invalid url', () => {
|
||||
const mockSetUrl = jest.fn();
|
||||
const mockUrl = 'invalid-url';
|
||||
const nodes = [
|
||||
'https://n00.api.vega.xyz',
|
||||
'https://n01.api.vega.xyz',
|
||||
'https://n02.api.vega.xyz',
|
||||
];
|
||||
mockEnv({
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
nodes,
|
||||
setUrl: mockSetUrl,
|
||||
});
|
||||
render(<NodeSwitcher closeDialog={jest.fn()} />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Connect to this node' })
|
||||
).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Other' }));
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: {
|
||||
value: mockUrl,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Connect to this node',
|
||||
})
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it.todo('displays errors');
|
||||
});
|
254
libs/environment/src/components/node-switcher/row-data.spec.tsx
Normal file
254
libs/environment/src/components/node-switcher/row-data.spec.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
import type { MockedResponse } from '@apollo/react-testing';
|
||||
import { MockedProvider } from '@apollo/react-testing';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { RadioGroup } from '@vegaprotocol/ui-toolkit';
|
||||
import type {
|
||||
BlockTimeSubscription,
|
||||
StatisticsQuery,
|
||||
} from '../../utils/__generated__/Node';
|
||||
import { BlockTimeDocument } from '../../utils/__generated__/Node';
|
||||
import { StatisticsDocument } from '../../utils/__generated__/Node';
|
||||
import type { RowDataProps } from './row-data';
|
||||
import { BLOCK_THRESHOLD, RowData } from './row-data';
|
||||
import type { HeaderEntry } from '@vegaprotocol/apollo-client';
|
||||
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||
import { CUSTOM_NODE_KEY } from '../../types';
|
||||
|
||||
jest.mock('@vegaprotocol/apollo-client', () => ({
|
||||
useHeaderStore: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
const statsQueryMock: MockedResponse<StatisticsQuery> = {
|
||||
request: {
|
||||
query: StatisticsDocument,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
statistics: {
|
||||
blockHeight: '1234',
|
||||
vegaTime: new Date().toISOString(),
|
||||
chainId: 'test-chain-id',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const subMock: MockedResponse<BlockTimeSubscription> = {
|
||||
request: {
|
||||
query: BlockTimeDocument,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
busEvents: [
|
||||
{
|
||||
__typename: 'BusEvent',
|
||||
id: '123',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponseTime = 50;
|
||||
global.performance.getEntriesByName = jest.fn().mockReturnValue([
|
||||
{
|
||||
duration: mockResponseTime,
|
||||
},
|
||||
]);
|
||||
|
||||
const mockHeaders = (
|
||||
url: string,
|
||||
headers: Partial<HeaderEntry> = {
|
||||
blockHeight: 100,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
) => {
|
||||
(useHeaderStore as unknown as jest.Mock).mockReturnValue({
|
||||
[url]: headers,
|
||||
});
|
||||
};
|
||||
|
||||
const renderComponent = (
|
||||
props: RowDataProps,
|
||||
queryMock: MockedResponse<StatisticsQuery>,
|
||||
subMock: MockedResponse<BlockTimeSubscription>
|
||||
) => {
|
||||
return (
|
||||
<MockedProvider mocks={[queryMock, subMock, subMock, subMock]}>
|
||||
<RadioGroup>
|
||||
{/* Radio group required as radio is being render in isolation */}
|
||||
<RowData {...props} />
|
||||
</RadioGroup>
|
||||
</MockedProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('RowData', () => {
|
||||
const props = {
|
||||
id: '0',
|
||||
url: 'https://foo.bar.com',
|
||||
highestBlock: null,
|
||||
onBlockHeight: jest.fn(),
|
||||
};
|
||||
|
||||
it('radio button enabled after stats query successful', async () => {
|
||||
mockHeaders(props.url);
|
||||
render(renderComponent(props, statsQueryMock, subMock));
|
||||
|
||||
// radio should be disabled until query resolves
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
checked: false,
|
||||
name: props.url,
|
||||
})
|
||||
).toBeDisabled();
|
||||
expect(screen.getByTestId('response-time-cell')).toHaveTextContent(
|
||||
'Checking'
|
||||
);
|
||||
expect(screen.getByTestId('block-height-cell')).toHaveTextContent(
|
||||
'Checking'
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('block-height-cell')).toHaveTextContent('100');
|
||||
expect(screen.getByTestId('response-time-cell')).toHaveTextContent(
|
||||
mockResponseTime.toFixed(2) + 'ms'
|
||||
);
|
||||
expect(screen.getByTestId('subscription-cell')).toHaveTextContent('Yes');
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
checked: false,
|
||||
name: props.url,
|
||||
})
|
||||
).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('radio button disabled if query fails', async () => {
|
||||
mockHeaders(props.url, {});
|
||||
|
||||
const failedQueryMock: MockedResponse<StatisticsQuery> = {
|
||||
request: {
|
||||
query: StatisticsDocument,
|
||||
},
|
||||
error: new Error('failed'),
|
||||
};
|
||||
|
||||
const failedSubMock: MockedResponse<BlockTimeSubscription> = {
|
||||
request: {
|
||||
query: BlockTimeDocument,
|
||||
},
|
||||
error: new Error('failed'),
|
||||
};
|
||||
|
||||
render(renderComponent(props, failedQueryMock, failedSubMock));
|
||||
|
||||
// radio should be disabled until query resolves
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
checked: false,
|
||||
name: props.url,
|
||||
})
|
||||
).toBeDisabled();
|
||||
expect(screen.getByTestId('response-time-cell')).toHaveTextContent(
|
||||
'Checking'
|
||||
);
|
||||
expect(screen.getByTestId('block-height-cell')).toHaveTextContent(
|
||||
'Checking'
|
||||
);
|
||||
await waitFor(() => {
|
||||
const responseCell = screen.getByTestId('response-time-cell');
|
||||
const blockHeightCell = screen.getByTestId('block-height-cell');
|
||||
const subscriptionCell = screen.getByTestId('subscription-cell');
|
||||
expect(responseCell).toHaveTextContent('n/a');
|
||||
expect(responseCell).toHaveClass('text-danger');
|
||||
expect(blockHeightCell).toHaveTextContent('n/a');
|
||||
expect(blockHeightCell).toHaveClass('text-danger');
|
||||
expect(subscriptionCell).toHaveTextContent('No');
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
checked: false,
|
||||
name: props.url,
|
||||
})
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('highlights rows with a slow block height', async () => {
|
||||
const blockHeight = 100;
|
||||
mockHeaders(props.url, { blockHeight });
|
||||
|
||||
const { rerender } = render(
|
||||
renderComponent(
|
||||
{ ...props, highestBlock: blockHeight + BLOCK_THRESHOLD },
|
||||
statsQueryMock,
|
||||
subMock
|
||||
)
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('block-height-cell')).toHaveTextContent(
|
||||
blockHeight.toString()
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('block-height-cell')).not.toHaveClass(
|
||||
'text-danger'
|
||||
);
|
||||
|
||||
rerender(
|
||||
renderComponent(
|
||||
{ ...props, highestBlock: blockHeight + BLOCK_THRESHOLD + 1 },
|
||||
statsQueryMock,
|
||||
subMock
|
||||
)
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('block-height-cell')).toHaveClass('text-danger');
|
||||
});
|
||||
|
||||
it('disables radio button if url is invalid', () => {
|
||||
mockHeaders(props.url, { blockHeight: 100 });
|
||||
|
||||
render(renderComponent(props, statsQueryMock, subMock));
|
||||
|
||||
expect(
|
||||
screen.getByRole('radio', {
|
||||
checked: false,
|
||||
name: props.url,
|
||||
})
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('doesnt render the radio if its the custom row', () => {
|
||||
render(
|
||||
renderComponent(
|
||||
{
|
||||
...props,
|
||||
id: CUSTOM_NODE_KEY,
|
||||
},
|
||||
statsQueryMock,
|
||||
subMock
|
||||
)
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('radio', {
|
||||
name: props.url,
|
||||
})
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates highest block after new header received', async () => {
|
||||
const mockOnBlockHeight = jest.fn();
|
||||
const blockHeight = 200;
|
||||
mockHeaders(props.url, { blockHeight });
|
||||
render(
|
||||
renderComponent(
|
||||
{ ...props, onBlockHeight: mockOnBlockHeight },
|
||||
statsQueryMock,
|
||||
subMock
|
||||
)
|
||||
);
|
||||
|
||||
expect(mockOnBlockHeight).toHaveBeenCalledWith(blockHeight);
|
||||
});
|
||||
});
|
207
libs/environment/src/components/node-switcher/row-data.tsx
Normal file
207
libs/environment/src/components/node-switcher/row-data.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||
import { isValidUrl, t } from '@vegaprotocol/react-helpers';
|
||||
import { Radio } from '@vegaprotocol/ui-toolkit';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CUSTOM_NODE_KEY } from '../../types';
|
||||
import {
|
||||
useBlockTimeSubscription,
|
||||
useStatisticsQuery,
|
||||
} from '../../utils/__generated__/Node';
|
||||
import { LayoutCell } from './layout-cell';
|
||||
|
||||
const POLL_INTERVAL = 1000;
|
||||
export const BLOCK_THRESHOLD = 3;
|
||||
|
||||
export interface RowDataProps {
|
||||
id: string;
|
||||
url: string;
|
||||
highestBlock: number | null;
|
||||
onBlockHeight: (blockHeight: number) => void;
|
||||
}
|
||||
|
||||
export const RowData = ({
|
||||
id,
|
||||
url,
|
||||
highestBlock,
|
||||
onBlockHeight,
|
||||
}: RowDataProps) => {
|
||||
const [time, setTime] = useState<number>();
|
||||
// no use of data here as we need the data nodes reference to block height
|
||||
const { data, error, loading, startPolling, stopPolling } =
|
||||
useStatisticsQuery({
|
||||
pollInterval: POLL_INTERVAL,
|
||||
// fix for pollInterval
|
||||
// https://github.com/apollographql/apollo-client/issues/9819
|
||||
ssr: false,
|
||||
});
|
||||
const headerStore = useHeaderStore();
|
||||
const headers = headerStore[url];
|
||||
|
||||
const {
|
||||
data: subData,
|
||||
error: subError,
|
||||
loading: subLoading,
|
||||
} = useBlockTimeSubscription();
|
||||
|
||||
useEffect(() => {
|
||||
// stop polling if row has errored
|
||||
}, [error, stopPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStartPoll = () => startPolling(POLL_INTERVAL);
|
||||
const handleStopPoll = () => stopPolling();
|
||||
|
||||
window.addEventListener('blur', handleStopPoll);
|
||||
window.addEventListener('focus', handleStartPoll);
|
||||
|
||||
handleStartPoll();
|
||||
|
||||
if (error) {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('blur', handleStopPoll);
|
||||
window.removeEventListener('focus', handleStartPoll);
|
||||
};
|
||||
}, [startPolling, stopPolling, error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValidUrl(url)) return;
|
||||
// every time we get data measure response speed
|
||||
const requestUrl = new URL(url);
|
||||
const requests = window.performance.getEntriesByName(requestUrl.href);
|
||||
const { duration } =
|
||||
(requests.length && requests[requests.length - 1]) || {};
|
||||
setTime(duration);
|
||||
}, [url, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (headers?.blockHeight) {
|
||||
onBlockHeight(headers.blockHeight);
|
||||
}
|
||||
}, [headers?.blockHeight, onBlockHeight]);
|
||||
|
||||
const getHasError = () => {
|
||||
// the stats query errored
|
||||
if (error) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if we are still awaiting a header entry its not an error
|
||||
// we are still waiting for the query to resolve
|
||||
if (!headers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// highlight this node as 'error' if its more than BLOCK_THRESHOLD blocks behind the most
|
||||
// advanced node
|
||||
if (
|
||||
highestBlock !== null &&
|
||||
headers.blockHeight < highestBlock - BLOCK_THRESHOLD
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getIsNodeDisabled = () => {
|
||||
if (!isValidUrl(url)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if still waiting or query errored disable node
|
||||
if (loading || error) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (subLoading || subError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if we are still waiting for a header entry for this
|
||||
// url disable the node
|
||||
if (!headers) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{id !== CUSTOM_NODE_KEY && (
|
||||
<div className="break-all" data-testid="node">
|
||||
<Radio
|
||||
id={`node-url-${id}`}
|
||||
value={url}
|
||||
label={url}
|
||||
disabled={getIsNodeDisabled()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<LayoutCell
|
||||
label={t('Response time')}
|
||||
isLoading={!error && loading}
|
||||
hasError={Boolean(error)}
|
||||
dataTestId="response-time-cell"
|
||||
>
|
||||
{getResponseTimeDisplayValue(time, error)}
|
||||
</LayoutCell>
|
||||
<LayoutCell
|
||||
label={t('Block')}
|
||||
isLoading={loading}
|
||||
hasError={getHasError()}
|
||||
dataTestId="block-height-cell"
|
||||
>
|
||||
{getBlockDisplayValue(headers?.blockHeight, error)}
|
||||
</LayoutCell>
|
||||
<LayoutCell
|
||||
label={t('Subscription')}
|
||||
isLoading={subLoading}
|
||||
hasError={Boolean(subError)}
|
||||
dataTestId="subscription-cell"
|
||||
>
|
||||
{getSubscriptionDisplayValue(subData?.busEvents, subError)}
|
||||
</LayoutCell>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getResponseTimeDisplayValue = (
|
||||
responseTime?: number,
|
||||
error?: ApolloError
|
||||
) => {
|
||||
if (error) {
|
||||
return t('n/a');
|
||||
}
|
||||
if (typeof responseTime === 'number') {
|
||||
return `${Number(responseTime).toFixed(2)}ms`;
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
const getBlockDisplayValue = (block?: number, error?: ApolloError) => {
|
||||
if (error) {
|
||||
return t('n/a');
|
||||
}
|
||||
if (block) {
|
||||
return block;
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
const getSubscriptionDisplayValue = (
|
||||
events?: { id: string }[] | null,
|
||||
error?: ApolloError
|
||||
) => {
|
||||
if (error) {
|
||||
return t('No');
|
||||
}
|
||||
if (events?.length) {
|
||||
return t('Yes');
|
||||
}
|
||||
return '-';
|
||||
};
|
@ -1,22 +0,0 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Networks } from '../types';
|
||||
import { EnvironmentProvider } from './use-environment';
|
||||
|
||||
describe('EnvironmentProvider', () => {
|
||||
beforeAll(() => {
|
||||
process.env['NX_MAINTENANCE_PAGE'] = 'true';
|
||||
process.env['NX_VEGA_URL'] = 'https://vega.xyz';
|
||||
process.env['NX_VEGA_ENV'] = Networks.TESTNET;
|
||||
});
|
||||
afterAll(() => {
|
||||
process.env['NX_MAINTENANCE_PAGE'] = '';
|
||||
});
|
||||
it('EnvironmentProvider should return maintenance page', async () => {
|
||||
await act(async () => {
|
||||
render(<EnvironmentProvider />);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('maintenance-page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
@ -1,140 +0,0 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type { EnvironmentWithOptionalUrl } from './use-config';
|
||||
import { useConfig } from './use-config';
|
||||
import { Networks, ErrorType } from '../types';
|
||||
|
||||
const mockConfig = {
|
||||
hosts: [
|
||||
'https://vega-host-1.com',
|
||||
'https://vega-host-2.com',
|
||||
'https://vega-host-3.com',
|
||||
'https://vega-host-4.com',
|
||||
],
|
||||
};
|
||||
|
||||
const mockEnvironment: EnvironmentWithOptionalUrl = {
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
VEGA_CONFIG_URL: 'https://vega.url/config.json',
|
||||
VEGA_NETWORKS: {},
|
||||
ETHEREUM_PROVIDER_URL: 'https://ethereum.provider',
|
||||
ETHERSCAN_URL: 'https://etherscan.url',
|
||||
GIT_BRANCH: 'test',
|
||||
GIT_ORIGIN_URL: 'https://github.com/test/repo',
|
||||
GIT_COMMIT_HASH: 'abcde01234',
|
||||
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
|
||||
};
|
||||
|
||||
function setupFetch(configUrl: string) {
|
||||
return (url: RequestInfo) => {
|
||||
if (url === configUrl) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockConfig),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
} as Response);
|
||||
};
|
||||
}
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const onError = jest.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
onError.mockClear();
|
||||
window.localStorage.clear();
|
||||
|
||||
// @ts-ignore typescript doesn't recognise the mocked instance
|
||||
global.fetch.mockReset();
|
||||
// @ts-ignore typescript doesn't recognise the mocked instance
|
||||
global.fetch.mockImplementation(
|
||||
setupFetch(mockEnvironment.VEGA_CONFIG_URL ?? '')
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useConfig hook', () => {
|
||||
it("doesn't update when there is no VEGA_CONFIG_URL in the environment", async () => {
|
||||
const mockEnvWithoutUrl = {
|
||||
...mockEnvironment,
|
||||
VEGA_CONFIG_URL: undefined,
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useConfig({ environment: mockEnvWithoutUrl }, onError)
|
||||
);
|
||||
|
||||
expect(result.current.config).toBe(undefined);
|
||||
});
|
||||
|
||||
it('fetches configuration from the provided url', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfig({ environment: mockEnvironment }, onError)
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(mockEnvironment.VEGA_CONFIG_URL);
|
||||
expect(result.current.config).toEqual(mockConfig);
|
||||
});
|
||||
});
|
||||
|
||||
it('executes the error callback when the config endpoint fails', async () => {
|
||||
// @ts-ignore typescript doesn't recognise the mocked instance
|
||||
global.fetch.mockImplementation(() => Promise.reject());
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConfig({ environment: mockEnvironment }, onError)
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.config).toEqual({ hosts: [] });
|
||||
expect(onError).toHaveBeenCalledWith(ErrorType.CONFIG_LOAD_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
it('executes the error callback when the config validation fails', async () => {
|
||||
// @ts-ignore typescript doesn't recognise the mocked instance
|
||||
global.fetch.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: 'not-valid-config' }),
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConfig({ environment: mockEnvironment }, onError)
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.config).toBe(undefined);
|
||||
expect(onError).toHaveBeenCalledWith(ErrorType.CONFIG_VALIDATION_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default config without getting it from the network when provided', async () => {
|
||||
const defaultConfig = { hosts: ['https://default.url'] };
|
||||
const { result } = renderHook(() =>
|
||||
useConfig(
|
||||
{
|
||||
environment: mockEnvironment,
|
||||
defaultConfig,
|
||||
},
|
||||
onError
|
||||
)
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.config).toBe(defaultConfig);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
@ -1,72 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ErrorType } from '../types';
|
||||
import type { Environment, Configuration } from '../types';
|
||||
import { validateConfiguration } from '../utils/validate-configuration';
|
||||
|
||||
export type EnvironmentWithOptionalUrl = Partial<Environment> &
|
||||
Omit<Environment, 'VEGA_URL'>;
|
||||
|
||||
const compileHosts = (hosts: string[], envUrl?: string) => {
|
||||
if (envUrl && !hosts.includes(envUrl)) {
|
||||
return [...hosts, envUrl];
|
||||
}
|
||||
return hosts;
|
||||
};
|
||||
|
||||
type UseConfigOptions = {
|
||||
environment: EnvironmentWithOptionalUrl;
|
||||
defaultConfig?: Configuration;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch list of hosts from the VEGA_CONFIG_URL
|
||||
*/
|
||||
export const useConfig = (
|
||||
{ environment, defaultConfig }: UseConfigOptions,
|
||||
onError: (errorType: ErrorType) => void
|
||||
) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [config, setConfig] = useState<Configuration | undefined>(
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
(async () => {
|
||||
if (!config && environment.VEGA_CONFIG_URL) {
|
||||
isMounted && setLoading(true);
|
||||
try {
|
||||
const response = await fetch(environment.VEGA_CONFIG_URL);
|
||||
const configData: Configuration = await response.json();
|
||||
|
||||
if (validateConfiguration(configData)) {
|
||||
onError(ErrorType.CONFIG_VALIDATION_ERROR);
|
||||
isMounted && setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const hosts = compileHosts(configData.hosts, environment.VEGA_URL);
|
||||
|
||||
isMounted && setConfig({ hosts });
|
||||
isMounted && setLoading(false);
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
setConfig({ hosts: [] });
|
||||
}
|
||||
onError(ErrorType.CONFIG_LOAD_ERROR);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
// load config only once per runtime
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [environment.VEGA_CONFIG_URL, !!config, onError, setLoading]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
config,
|
||||
};
|
||||
};
|
@ -1,218 +0,0 @@
|
||||
// having the node switcher dialog in the environment provider breaks the test renderer
|
||||
// workaround based on: https://github.com/facebook/react/issues/11565
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { createClient } from '@vegaprotocol/apollo-client';
|
||||
import { useEnvironment, EnvironmentProvider } from './use-environment';
|
||||
import { Networks } from '../types';
|
||||
import createMockClient from './mocks/apollo-client';
|
||||
jest.mock('@vegaprotocol/apollo-client');
|
||||
|
||||
jest.mock('react-dom', () => ({
|
||||
...jest.requireActual('react-dom'),
|
||||
createPortal: (node: ReactNode) => node,
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const MockWrapper = (props: ComponentProps<typeof EnvironmentProvider>) => {
|
||||
return <EnvironmentProvider {...props} />;
|
||||
};
|
||||
|
||||
const MOCK_HOST = 'https://vega.host/query';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const noop = () => {};
|
||||
|
||||
const mockEnvironmentState = {
|
||||
VEGA_URL: 'https://vega.xyz',
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
VEGA_CONFIG_URL: 'https://vega.xyz/testnet-config.json',
|
||||
VEGA_NETWORKS: {
|
||||
TESTNET: 'https://testnet.url',
|
||||
STAGNET: 'https://stagnet.url',
|
||||
MAINNET: 'https://mainnet.url',
|
||||
},
|
||||
ETHEREUM_PROVIDER_URL: 'https://ether.provider',
|
||||
ETHERSCAN_URL: 'https://etherscan.url',
|
||||
GIT_BRANCH: 'test',
|
||||
GIT_ORIGIN_URL: 'https://github.com/test/repo',
|
||||
GIT_COMMIT_HASH: 'abcde01234',
|
||||
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
|
||||
setNodeSwitcherOpen: noop,
|
||||
networkError: undefined,
|
||||
};
|
||||
|
||||
const MOCK_DURATION = 76;
|
||||
|
||||
window.performance.getEntriesByName = jest
|
||||
.fn()
|
||||
.mockImplementation((url: string) => [
|
||||
{
|
||||
entryType: 'resource',
|
||||
name: url,
|
||||
startTime: 0,
|
||||
toJSON: () => ({}),
|
||||
duration: MOCK_DURATION,
|
||||
},
|
||||
]);
|
||||
|
||||
function setupFetch(
|
||||
configUrl: string = mockEnvironmentState.VEGA_CONFIG_URL,
|
||||
hosts?: string[]
|
||||
) {
|
||||
return (url: RequestInfo) => {
|
||||
if (url === configUrl) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ hosts: hosts || [MOCK_HOST] }),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
} as Response);
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-ignore: typescript doesn't recognize the mock implementation
|
||||
global.fetch.mockImplementation(setupFetch());
|
||||
|
||||
window.localStorage.clear();
|
||||
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation(() => createMockClient());
|
||||
|
||||
process.env['NX_VEGA_URL'] = mockEnvironmentState.VEGA_URL;
|
||||
process.env['NX_VEGA_ENV'] = mockEnvironmentState.VEGA_ENV;
|
||||
process.env['NX_VEGA_CONFIG_URL'] = mockEnvironmentState.VEGA_CONFIG_URL;
|
||||
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(
|
||||
mockEnvironmentState.VEGA_NETWORKS
|
||||
);
|
||||
process.env['NX_ETHEREUM_PROVIDER_URL'] =
|
||||
mockEnvironmentState.ETHEREUM_PROVIDER_URL;
|
||||
process.env['NX_ETHERSCAN_URL'] = mockEnvironmentState.ETHERSCAN_URL;
|
||||
process.env['NX_GIT_BRANCH'] = mockEnvironmentState.GIT_BRANCH;
|
||||
process.env['NX_GIT_ORIGIN_URL'] = mockEnvironmentState.GIT_ORIGIN_URL;
|
||||
process.env['NX_GIT_COMMIT_HASH'] = mockEnvironmentState.GIT_COMMIT_HASH;
|
||||
process.env['NX_GITHUB_FEEDBACK_URL'] =
|
||||
mockEnvironmentState.GITHUB_FEEDBACK_URL;
|
||||
});
|
||||
|
||||
describe('throws error', () => {
|
||||
const consoleError = console.error;
|
||||
|
||||
beforeAll(() => {
|
||||
console.error = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = consoleError;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-ignore: typescript doesn't recognize the mock implementation
|
||||
global.fetch.mockImplementation(setupFetch());
|
||||
|
||||
window.localStorage.clear();
|
||||
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation(() => createMockClient());
|
||||
|
||||
process.env['NX_VEGA_URL'] = mockEnvironmentState.VEGA_URL;
|
||||
process.env['NX_VEGA_ENV'] = mockEnvironmentState.VEGA_ENV;
|
||||
process.env['NX_VEGA_CONFIG_URL'] = mockEnvironmentState.VEGA_CONFIG_URL;
|
||||
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(
|
||||
mockEnvironmentState.VEGA_NETWORKS
|
||||
);
|
||||
process.env['NX_ETHEREUM_PROVIDER_URL'] =
|
||||
mockEnvironmentState.ETHEREUM_PROVIDER_URL;
|
||||
process.env['NX_ETHERSCAN_URL'] = mockEnvironmentState.ETHERSCAN_URL;
|
||||
process.env['NX_GIT_BRANCH'] = mockEnvironmentState.GIT_BRANCH;
|
||||
process.env['NX_GIT_ORIGIN_URL'] = mockEnvironmentState.GIT_ORIGIN_URL;
|
||||
process.env['NX_GIT_COMMIT_HASH'] = mockEnvironmentState.GIT_COMMIT_HASH;
|
||||
process.env['NX_GITHUB_FEEDBACK_URL'] =
|
||||
mockEnvironmentState.GITHUB_FEEDBACK_URL;
|
||||
});
|
||||
|
||||
beforeEach(() => jest.resetModules()); // clears the cache of the modules
|
||||
|
||||
it('throws a validation error when NX_ETHERSCAN_URL is not a valid url', () => {
|
||||
process.env['NX_ETHERSCAN_URL'] = 'invalid-url';
|
||||
const result = () =>
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
expect(result).toThrow(
|
||||
`The NX_ETHERSCAN_URL environment variable must be a valid url`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a validation error when NX_ETHEREUM_PROVIDER_URL is not a valid url', () => {
|
||||
process.env['NX_ETHEREUM_PROVIDER_URL'] = 'invalid-url';
|
||||
const result = () =>
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
expect(result).toThrow(
|
||||
`The NX_ETHEREUM_PROVIDER_URL environment variable must be a valid url`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a validation error when VEGA_NETWORKS has an invalid network as a key', () => {
|
||||
process.env['NX_VEGA_NETWORKS'] = JSON.stringify({
|
||||
NOT_A_NETWORK: 'https://somewhere.url',
|
||||
});
|
||||
const result = () =>
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
expect(result).toThrow(
|
||||
`Error processing the vega app environment:
|
||||
- All keys in NX_VEGA_NETWORKS must represent a valid environment: ${Object.keys(
|
||||
Networks
|
||||
).join(' | ')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a validation error when both VEGA_URL and VEGA_CONFIG_URL are missing in the environment', () => {
|
||||
delete process.env['NX_VEGA_URL'];
|
||||
delete process.env['NX_VEGA_CONFIG_URL'];
|
||||
const result = () =>
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
expect(result).toThrow(
|
||||
`Must provide either NX_VEGA_CONFIG_URL or NX_VEGA_URL in the environment.`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a validation error when NX_VEGA_ENV is not found in the environment', () => {
|
||||
delete process.env['NX_VEGA_ENV'];
|
||||
const result = () =>
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
expect(result).toThrow(
|
||||
`Error processing the vega app environment:
|
||||
- NX_VEGA_ENV is invalid, received "undefined" instead of: '${Object.keys(
|
||||
Networks
|
||||
).join("' | '")}'`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a validation error when VEGA_ENV is not a valid network', () => {
|
||||
process.env['NX_VEGA_ENV'] = 'SOMETHING';
|
||||
const result = () =>
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
expect(result).not.toThrow(
|
||||
`Error processing the vega app environment:
|
||||
- NX_VEGA_ENV is invalid, received "SOMETHING" instead of: '${Object.keys(
|
||||
Networks
|
||||
).join("' | '")}'`
|
||||
);
|
||||
});
|
||||
});
|
259
libs/environment/src/hooks/use-environment.spec.ts
Normal file
259
libs/environment/src/hooks/use-environment.spec.ts
Normal file
@ -0,0 +1,259 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Networks } from '../types';
|
||||
import { useEnvironment } from './use-environment';
|
||||
|
||||
const noop = () => {
|
||||
/* no op*/
|
||||
};
|
||||
|
||||
jest.mock('@vegaprotocol/apollo-client', () => ({
|
||||
createClient: () => ({
|
||||
query: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
statistics: {
|
||||
chainId: 'chain-id',
|
||||
blockHeight: '100',
|
||||
vegaTime: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
subscribe: () => ({
|
||||
// eslint-disable-next-line
|
||||
subscribe: (obj: any) => {
|
||||
obj.next();
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
jest.mock('zustand');
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
// eslint-disable-next-line
|
||||
const setupFetch = (result: any) => {
|
||||
return () => {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(result),
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const mockEnvVars = {
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
VEGA_NETWORKS: {
|
||||
DEVNET: 'https://devnet.url',
|
||||
TESTNET: 'https://testnet.url',
|
||||
STAGNET3: 'https://stagnet3.url',
|
||||
MAINNET: 'https://mainnet.url',
|
||||
},
|
||||
VEGA_WALLET_URL: 'https://localhost:1234',
|
||||
ETHEREUM_PROVIDER_URL: 'https://ether.provider',
|
||||
ETHERSCAN_URL: 'https://etherscan.url',
|
||||
};
|
||||
|
||||
describe('useEnvironment', () => {
|
||||
const env = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
process.env = { ...env };
|
||||
|
||||
process.env['NX_VEGA_ENV'] = mockEnvVars.VEGA_ENV;
|
||||
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(mockEnvVars.VEGA_NETWORKS);
|
||||
process.env['NX_ETHEREUM_PROVIDER_URL'] = mockEnvVars.ETHEREUM_PROVIDER_URL;
|
||||
process.env['NX_VEGA_WALLET_URL'] = mockEnvVars.VEGA_WALLET_URL;
|
||||
process.env['NX_ETHERSCAN_URL'] = mockEnvVars.ETHERSCAN_URL;
|
||||
|
||||
// if config is fetched resulting suitable node
|
||||
// will be stored in localStorage
|
||||
localStorage.clear();
|
||||
|
||||
// @ts-ignore clear mocked node config fetch
|
||||
fetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = env;
|
||||
});
|
||||
|
||||
const setup = () => {
|
||||
return renderHook(() => useEnvironment());
|
||||
};
|
||||
|
||||
it('exposes env vars and sets VEGA_URL from config nodes', async () => {
|
||||
const configUrl = 'https://vega.xyz/testnet-config.json';
|
||||
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
|
||||
const nodes = [
|
||||
'https://api.n00.foo.vega.xyz',
|
||||
'https://api.n01.foo.vega.xyz',
|
||||
];
|
||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||
global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
|
||||
const { result } = setup();
|
||||
|
||||
expect(result.current.status).toBe('default');
|
||||
|
||||
act(() => {
|
||||
result.current.initialize();
|
||||
});
|
||||
|
||||
expect(result.current.status).toBe('pending');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toBe('success');
|
||||
});
|
||||
|
||||
// resulting VEGA_URL should be one of the nodes from the config
|
||||
expect(
|
||||
result.current.VEGA_URL === nodes[0] ||
|
||||
result.current.VEGA_URL === nodes[1]
|
||||
).toBe(true);
|
||||
expect(result.current).toMatchObject({
|
||||
...mockEnvVars,
|
||||
nodes,
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(configUrl);
|
||||
});
|
||||
|
||||
it('sets error if environment is invalid', async () => {
|
||||
const error = console.error;
|
||||
console.error = noop;
|
||||
process.env['NX_VEGA_ENV'] = undefined; // VEGA_ENV is required by zod schema
|
||||
const { result } = setup();
|
||||
await act(async () => {
|
||||
result.current.initialize();
|
||||
});
|
||||
expect(result.current).toMatchObject({
|
||||
status: 'failed',
|
||||
error: 'Error processing the Vega environment',
|
||||
});
|
||||
console.error = error;
|
||||
});
|
||||
|
||||
it('errors if neither VEGA_URL or VEGA_CONFIG_URL are set', async () => {
|
||||
const error = console.error;
|
||||
console.error = noop;
|
||||
process.env['NX_VEGA_ENV'] = undefined;
|
||||
process.env['NX_VEGA_URL'] = undefined;
|
||||
const { result } = setup();
|
||||
await act(async () => {
|
||||
result.current.initialize();
|
||||
});
|
||||
expect(result.current).toMatchObject({
|
||||
status: 'failed',
|
||||
});
|
||||
console.error = error;
|
||||
});
|
||||
|
||||
it('allows for undefined VEGA_CONFIG_URL if VEGA_URL is set', async () => {
|
||||
const url = 'https://my.vega.url';
|
||||
process.env['NX_VEGA_URL'] = url;
|
||||
process.env['NX_VEGA_CONFIG_URL'] = undefined;
|
||||
const { result } = setup();
|
||||
await act(async () => {
|
||||
result.current.initialize();
|
||||
});
|
||||
expect(result.current).toMatchObject({
|
||||
VEGA_URL: url,
|
||||
VEGA_CONFIG_URL: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows for undefined VEGA_URL if VEGA_CONFIG_URL is set', async () => {
|
||||
const configUrl = 'https://vega.xyz/testnet-config.json';
|
||||
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
|
||||
process.env['NX_VEGA_URL'] = undefined;
|
||||
const nodes = [
|
||||
'https://api.n00.foo.vega.xyz',
|
||||
'https://api.n01.foo.vega.xyz',
|
||||
];
|
||||
// @ts-ignore setup mock fetch for config url
|
||||
global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
|
||||
const { result } = setup();
|
||||
await act(async () => {
|
||||
result.current.initialize();
|
||||
});
|
||||
expect(typeof result.current.VEGA_URL).toEqual('string');
|
||||
expect(result.current.VEGA_URL).not.toBeFalsy();
|
||||
});
|
||||
|
||||
it('handles error if node config cannot be fetched', async () => {
|
||||
const warn = console.warn;
|
||||
console.warn = noop;
|
||||
const configUrl = 'https://vega.xyz/testnet-config.json';
|
||||
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
|
||||
process.env['NX_VEGA_URL'] = undefined;
|
||||
// @ts-ignore setup mock fetch for config url
|
||||
global.fetch.mockImplementation(() => {
|
||||
throw new Error('failed to fetch');
|
||||
});
|
||||
const { result } = setup();
|
||||
await act(async () => {
|
||||
result.current.initialize();
|
||||
});
|
||||
expect(result.current.status).toEqual('failed');
|
||||
expect(typeof result.current.error).toBe('string');
|
||||
expect(result.current.error).toBeTruthy();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(configUrl);
|
||||
console.warn = warn;
|
||||
});
|
||||
|
||||
it('handles an invalid node config', async () => {
|
||||
const warn = console.warn;
|
||||
console.warn = noop;
|
||||
const configUrl = 'https://vega.xyz/testnet-config.json';
|
||||
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
|
||||
process.env['NX_VEGA_URL'] = undefined;
|
||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||
global.fetch.mockImplementation(setupFetch({ invalid: 'invalid' }));
|
||||
const { result } = setup();
|
||||
await act(async () => {
|
||||
result.current.initialize();
|
||||
});
|
||||
expect(result.current.status).toEqual('failed');
|
||||
expect(typeof result.current.error).toBe('string');
|
||||
expect(result.current.error).toBeTruthy();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(configUrl);
|
||||
console.warn = warn;
|
||||
});
|
||||
|
||||
it('uses stored url', async () => {
|
||||
const configUrl = 'https://vega.xyz/testnet-config.json';
|
||||
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
|
||||
// @ts-ignore setup mock fetch for config url
|
||||
global.fetch.mockImplementation(
|
||||
setupFetch({ hosts: ['http://foo.bar.com'] })
|
||||
);
|
||||
const url = 'https://api.n00.foo.com';
|
||||
localStorage.setItem('vega_url', url);
|
||||
const { result } = setup();
|
||||
await act(async () => {
|
||||
result.current.initialize();
|
||||
});
|
||||
expect(result.current.VEGA_URL).toBe(url);
|
||||
expect(result.current.status).toBe('success');
|
||||
});
|
||||
|
||||
it('can update VEGA_URL', async () => {
|
||||
const url = 'https://api.n00.foo.com';
|
||||
const newUrl = 'http://foo.bar.com';
|
||||
process.env['NX_VEGA_URL'] = url;
|
||||
const { result } = setup();
|
||||
await act(async () => {
|
||||
result.current.initialize();
|
||||
});
|
||||
expect(result.current.VEGA_URL).toBe(url);
|
||||
expect(result.current.status).toBe('success');
|
||||
act(() => {
|
||||
result.current.setUrl(newUrl);
|
||||
});
|
||||
expect(result.current.VEGA_URL).toBe(newUrl);
|
||||
expect(localStorage.getItem('vega_url')).toBe(newUrl);
|
||||
});
|
||||
});
|
@ -1,609 +0,0 @@
|
||||
// having the node switcher dialog in the environment provider breaks the test renderer
|
||||
// workaround based on: https://github.com/facebook/react/issues/11565
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type { ClientOptions } from '@vegaprotocol/apollo-client';
|
||||
import { createClient } from '@vegaprotocol/apollo-client';
|
||||
import { useEnvironment, EnvironmentProvider } from './use-environment';
|
||||
import { Networks, ErrorType } from '../types';
|
||||
import type { MockRequestConfig } from './mocks/apollo-client';
|
||||
import createMockClient from './mocks/apollo-client';
|
||||
import { getErrorByType } from '../utils/validate-node';
|
||||
|
||||
jest.mock('@vegaprotocol/apollo-client');
|
||||
|
||||
jest.mock('react-dom', () => ({
|
||||
...jest.requireActual('react-dom'),
|
||||
createPortal: (node: ReactNode) => node,
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const MockWrapper = (props: ComponentProps<typeof EnvironmentProvider>) => {
|
||||
return <EnvironmentProvider {...props} />;
|
||||
};
|
||||
|
||||
const MOCK_HOST = 'https://vega.host/query';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const noop = () => {};
|
||||
|
||||
const mockEnvironmentState = {
|
||||
VEGA_URL: 'https://vega.xyz',
|
||||
VEGA_ENV: Networks.TESTNET,
|
||||
VEGA_CONFIG_URL: 'https://vega.xyz/testnet-config.json',
|
||||
VEGA_NETWORKS: {
|
||||
DEVNET: 'https://devnet.url',
|
||||
TESTNET: 'https://testnet.url',
|
||||
STAGNET3: 'https://stagnet3.url',
|
||||
MAINNET: 'https://mainnet.url',
|
||||
},
|
||||
ETHEREUM_PROVIDER_URL: 'https://ether.provider',
|
||||
ETHERSCAN_URL: 'https://etherscan.url',
|
||||
GIT_BRANCH: 'test',
|
||||
GIT_ORIGIN_URL: 'https://github.com/test/repo',
|
||||
GIT_COMMIT_HASH: 'abcde01234',
|
||||
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
|
||||
MAINTENANCE_PAGE: false,
|
||||
configLoading: false,
|
||||
blockDifference: 0,
|
||||
nodeSwitcherOpen: false,
|
||||
setNodeSwitcherOpen: noop,
|
||||
networkError: undefined,
|
||||
};
|
||||
|
||||
const MOCK_DURATION = 76;
|
||||
|
||||
window.performance.getEntriesByName = jest
|
||||
.fn()
|
||||
.mockImplementation((url: string) => [
|
||||
{
|
||||
entryType: 'resource',
|
||||
name: url,
|
||||
startTime: 0,
|
||||
toJSON: () => ({}),
|
||||
duration: MOCK_DURATION,
|
||||
},
|
||||
]);
|
||||
|
||||
function setupFetch(
|
||||
configUrl: string = mockEnvironmentState.VEGA_CONFIG_URL,
|
||||
hosts?: string[]
|
||||
) {
|
||||
return (url: RequestInfo) => {
|
||||
if (url === configUrl) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ hosts: hosts || [MOCK_HOST] }),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
} as Response);
|
||||
};
|
||||
}
|
||||
|
||||
const getQuickestNode = (mockNodes: Record<string, MockRequestConfig>) => {
|
||||
const { nodeUrl } = Object.keys(mockNodes).reduce<{
|
||||
nodeUrl?: string;
|
||||
delay: number;
|
||||
}>(
|
||||
(acc, url) => {
|
||||
const { delay = 0, hasError = false } = mockNodes[url];
|
||||
if (!hasError && delay < acc.delay) {
|
||||
return { nodeUrl: url, delay };
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ nodeUrl: undefined, delay: Infinity }
|
||||
);
|
||||
return nodeUrl;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||
global.fetch.mockImplementation(setupFetch());
|
||||
|
||||
window.localStorage.clear();
|
||||
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation(() => createMockClient());
|
||||
|
||||
process.env['NX_VEGA_URL'] = mockEnvironmentState.VEGA_URL;
|
||||
process.env['NX_VEGA_ENV'] = mockEnvironmentState.VEGA_ENV;
|
||||
process.env['NX_VEGA_CONFIG_URL'] = mockEnvironmentState.VEGA_CONFIG_URL;
|
||||
process.env['NX_VEGA_NETWORKS'] = JSON.stringify(
|
||||
mockEnvironmentState.VEGA_NETWORKS
|
||||
);
|
||||
process.env['NX_ETHEREUM_PROVIDER_URL'] =
|
||||
mockEnvironmentState.ETHEREUM_PROVIDER_URL;
|
||||
process.env['NX_ETHERSCAN_URL'] = mockEnvironmentState.ETHERSCAN_URL;
|
||||
process.env['NX_GIT_BRANCH'] = mockEnvironmentState.GIT_BRANCH;
|
||||
process.env['NX_GIT_ORIGIN_URL'] = mockEnvironmentState.GIT_ORIGIN_URL;
|
||||
process.env['NX_GIT_COMMIT_HASH'] = mockEnvironmentState.GIT_COMMIT_HASH;
|
||||
process.env['NX_GITHUB_FEEDBACK_URL'] =
|
||||
mockEnvironmentState.GITHUB_FEEDBACK_URL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useEnvironment hook', () => {
|
||||
it('transforms and exposes values from the environment', async () => {
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('allows for the VEGA_CONFIG_URL to be missing when there is a VEGA_URL present', async () => {
|
||||
delete process.env['NX_VEGA_CONFIG_URL'];
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
VEGA_CONFIG_URL: undefined,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('allows for the VEGA_NETWORKS to be missing from the environment', async () => {
|
||||
delete process.env['NX_VEGA_NETWORKS'];
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
VEGA_NETWORKS: {
|
||||
TESTNET: window.location.origin,
|
||||
},
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a validation error when NX_VEGA_ENV is not found in the environment', async () => {
|
||||
delete process.env['NX_VEGA_ENV'];
|
||||
const consoleError = console.error;
|
||||
console.error = noop;
|
||||
expect(() => {
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
}).toThrowError(
|
||||
`NX_VEGA_ENV is invalid, received "undefined" instead of: 'CUSTOM' | 'SANDBOX' | 'TESTNET' | 'STAGNET1' | 'STAGNET3' | 'DEVNET' | 'MAINNET' | 'MIRROR'`
|
||||
);
|
||||
console.error = consoleError;
|
||||
});
|
||||
|
||||
it('throws a validation error when VEGA_ENV is not a valid network', async () => {
|
||||
process.env['NX_VEGA_ENV'] = 'SOMETHING';
|
||||
const consoleError = console.error;
|
||||
console.error = noop;
|
||||
expect(() => {
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
}).toThrowError(
|
||||
`NX_VEGA_ENV is invalid, received "SOMETHING" instead of: CUSTOM | SANDBOX | TESTNET | STAGNET1 | STAGNET3 | DEVNET | MAINNET | MIRROR`
|
||||
);
|
||||
console.error = consoleError;
|
||||
});
|
||||
|
||||
it('when VEGA_NETWORKS is not a valid json, prints a warning and continues without using the value from it', async () => {
|
||||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(noop);
|
||||
process.env['NX_VEGA_NETWORKS'] = '{not:{valid:json';
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
VEGA_NETWORKS: {
|
||||
TESTNET: window.location.origin,
|
||||
},
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('throws a validation error when VEGA_NETWORKS has an invalid network as a key', async () => {
|
||||
process.env['NX_VEGA_NETWORKS'] = JSON.stringify({
|
||||
NOT_A_NETWORK: 'https://somewhere.url',
|
||||
});
|
||||
const consoleError = console.error;
|
||||
console.error = noop;
|
||||
expect(() => {
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
}).toThrowError(
|
||||
`All keys in NX_VEGA_NETWORKS must represent a valid environment: CUSTOM | SANDBOX | TESTNET | STAGNET1 | STAGNET3 | DEVNET | MAINNET | MIRROR`
|
||||
);
|
||||
console.error = consoleError;
|
||||
});
|
||||
|
||||
it('throws a validation error when both VEGA_URL and VEGA_CONFIG_URL are missing in the environment', async () => {
|
||||
delete process.env['NX_VEGA_URL'];
|
||||
delete process.env['NX_VEGA_CONFIG_URL'];
|
||||
const consoleError = console.error;
|
||||
console.error = noop;
|
||||
expect(() => {
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
}).toThrowError(
|
||||
`Must provide either NX_VEGA_CONFIG_URL or NX_VEGA_URL in the environment.`
|
||||
);
|
||||
console.error = consoleError;
|
||||
});
|
||||
|
||||
it.each`
|
||||
env | etherscanUrl | providerUrl
|
||||
${Networks.DEVNET} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
|
||||
${Networks.TESTNET} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
|
||||
${Networks.STAGNET3} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
|
||||
${Networks.MAINNET} | ${'https://etherscan.io'} | ${'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'}
|
||||
`(
|
||||
'uses correct default ethereum connection variables in $env',
|
||||
async ({ env, etherscanUrl, providerUrl }) => {
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation(() => createMockClient({ network: env }));
|
||||
|
||||
process.env['NX_VEGA_ENV'] = env;
|
||||
delete process.env['NX_ETHEREUM_PROVIDER_URL'];
|
||||
delete process.env['NX_ETHERSCAN_URL'];
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
VEGA_ENV: env,
|
||||
ETHEREUM_PROVIDER_URL: providerUrl,
|
||||
ETHERSCAN_URL: etherscanUrl,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('throws a validation error when NX_ETHERSCAN_URL is not a valid url', async () => {
|
||||
process.env['NX_ETHERSCAN_URL'] = 'invalid-url';
|
||||
const consoleError = console.error;
|
||||
console.error = noop;
|
||||
expect(() => {
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
}).toThrowError(
|
||||
`The NX_ETHERSCAN_URL environment variable must be a valid url`
|
||||
);
|
||||
console.error = consoleError;
|
||||
});
|
||||
|
||||
it('throws a validation error when NX_ETHEREUM_PROVIDER_URL is not a valid url', async () => {
|
||||
process.env['NX_ETHEREUM_PROVIDER_URL'] = 'invalid-url';
|
||||
const consoleError = console.error;
|
||||
console.error = noop;
|
||||
expect(() => {
|
||||
renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
}).toThrow(
|
||||
`The NX_ETHEREUM_PROVIDER_URL environment variable must be a valid url`
|
||||
);
|
||||
console.error = consoleError;
|
||||
});
|
||||
|
||||
describe('node selection', () => {
|
||||
it('updates the VEGA_URL from the config when it is missing from the environment', async () => {
|
||||
delete process.env['NX_VEGA_URL'];
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
VEGA_URL: MOCK_HOST,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('updates the VEGA_URL with the quickest node to respond from the config urls', async () => {
|
||||
delete process.env['NX_VEGA_URL'];
|
||||
|
||||
const mockNodes: Record<string, MockRequestConfig> = {
|
||||
'https://mock-node-1.com': { hasError: false, delay: 4 },
|
||||
'https://mock-node-2.com': { hasError: false, delay: 5 },
|
||||
'https://mock-node-3.com': { hasError: false, delay: 8 },
|
||||
'https://mock-node-4.com': { hasError: false, delay: 0 },
|
||||
};
|
||||
|
||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||
global.fetch.mockImplementation(
|
||||
setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes))
|
||||
);
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation((cfg: ClientOptions) => {
|
||||
// eslint-disable-next-line
|
||||
return createMockClient({ statistics: mockNodes[cfg.url!] });
|
||||
});
|
||||
|
||||
const nodeUrl = getQuickestNode(mockNodes);
|
||||
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
VEGA_URL: nodeUrl,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores failing nodes and selects the first successful one to use', async () => {
|
||||
delete process.env['NX_VEGA_URL'];
|
||||
|
||||
const mockNodes: Record<string, MockRequestConfig> = {
|
||||
'https://mock-node-1.com': { hasError: true, delay: 4 },
|
||||
'https://mock-node-2.com': { hasError: false, delay: 5 },
|
||||
'https://mock-node-3.com': { hasError: false, delay: 8 },
|
||||
'https://mock-node-4.com': { hasError: true, delay: 0 },
|
||||
};
|
||||
|
||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||
global.fetch.mockImplementation(
|
||||
setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes))
|
||||
);
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation((cfg: ClientOptions) => {
|
||||
// eslint-disable-next-line
|
||||
return createMockClient({ statistics: mockNodes[cfg.url!] });
|
||||
});
|
||||
|
||||
const nodeUrl = getQuickestNode(mockNodes);
|
||||
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
VEGA_URL: nodeUrl,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('has a network error when cannot connect to any nodes', async () => {
|
||||
delete process.env['NX_VEGA_URL'];
|
||||
|
||||
const mockNodes: Record<string, MockRequestConfig> = {
|
||||
'https://mock-node-1.com': { hasError: true, delay: 4 },
|
||||
'https://mock-node-2.com': { hasError: true, delay: 5 },
|
||||
'https://mock-node-3.com': { hasError: true, delay: 8 },
|
||||
'https://mock-node-4.com': { hasError: true, delay: 0 },
|
||||
};
|
||||
|
||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||
global.fetch.mockImplementation(
|
||||
setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes))
|
||||
);
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation((cfg: ClientOptions) => {
|
||||
// eslint-disable-next-line
|
||||
return createMockClient({ statistics: mockNodes[cfg.url!] });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
VEGA_URL: undefined,
|
||||
networkError: ErrorType.CONNECTION_ERROR_ALL,
|
||||
nodeSwitcherOpen: true,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('has a network error when it cannot fetch the network config and there is no VEGA_URL in the environment', async () => {
|
||||
delete process.env['NX_VEGA_URL'];
|
||||
|
||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||
global.fetch.mockImplementation(() => {
|
||||
throw new Error('Cannot fetch');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
VEGA_URL: undefined,
|
||||
networkError: ErrorType.CONFIG_LOAD_ERROR,
|
||||
nodeSwitcherOpen: true,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('logs an error when it cannot fetch the network config and there is a VEGA_URL in the environment', async () => {
|
||||
const consoleWarnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(noop);
|
||||
|
||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||
global.fetch.mockImplementation(() => {
|
||||
throw new Error('Cannot fetch');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
nodeSwitcherOpen: false,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
getErrorByType(
|
||||
ErrorType.CONFIG_LOAD_ERROR,
|
||||
mockEnvironmentState.VEGA_ENV
|
||||
)?.headline
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// SKIP due to https://github.com/facebook/jest/issues/12670
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('has a network error when the config is invalid and there is no VEGA_URL in the environment', async () => {
|
||||
delete process.env['NX_VEGA_URL'];
|
||||
|
||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||
global.fetch.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ some: 'invalid-object' }),
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
VEGA_URL: undefined,
|
||||
networkError: ErrorType.CONFIG_VALIDATION_ERROR,
|
||||
nodeSwitcherOpen: true,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// SKIP due to https://github.com/facebook/jest/issues/12670
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('logs an error when the network config is invalid and there is a VEGA_URL in the environment', async () => {
|
||||
const consoleWarnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(noop);
|
||||
|
||||
// @ts-ignore: typscript doesn't recognise the mock implementation
|
||||
global.fetch.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ some: 'invalid-object' }),
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
expect(result.current.configLoading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
getErrorByType(
|
||||
ErrorType.CONFIG_VALIDATION_ERROR,
|
||||
mockEnvironmentState.VEGA_ENV
|
||||
)?.headline
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('has a network error when the selected node is not a valid url', async () => {
|
||||
process.env['NX_VEGA_URL'] = 'not-url';
|
||||
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
VEGA_URL: 'not-url',
|
||||
nodeSwitcherOpen: true,
|
||||
networkError: ErrorType.INVALID_URL,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('has a network error when cannot connect to the selected node', async () => {
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation(() => {
|
||||
return createMockClient({ statistics: { hasError: true } });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
nodeSwitcherOpen: true,
|
||||
networkError: ErrorType.CONNECTION_ERROR,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('has a network error when the selected node has no subscription available', async () => {
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation(() => {
|
||||
return createMockClient({ busEvents: { hasError: true } });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEnvironment(), {
|
||||
wrapper: MockWrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual({
|
||||
...mockEnvironmentState,
|
||||
networkError: ErrorType.SUBSCRIPTION_ERROR,
|
||||
setNodeSwitcherOpen: result.current.setNodeSwitcherOpen,
|
||||
nodeSwitcherOpen: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
315
libs/environment/src/hooks/use-environment.ts
Normal file
315
libs/environment/src/hooks/use-environment.ts
Normal file
@ -0,0 +1,315 @@
|
||||
import { isValidUrl, LocalStorage, t } from '@vegaprotocol/react-helpers';
|
||||
import { useEffect } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { createClient } from '@vegaprotocol/apollo-client';
|
||||
import type {
|
||||
BlockTimeSubscription,
|
||||
StatisticsQuery,
|
||||
} from '../utils/__generated__/Node';
|
||||
import { BlockTimeDocument } from '../utils/__generated__/Node';
|
||||
import { StatisticsDocument } from '../utils/__generated__/Node';
|
||||
import type { Environment } from '../types';
|
||||
import { Networks } from '../types';
|
||||
import { compileErrors } from '../utils/compile-errors';
|
||||
import { envSchema } from '../utils/validate-environment';
|
||||
import { configSchema } from '../utils/validate-configuration';
|
||||
|
||||
type Client = ReturnType<typeof createClient>;
|
||||
type ClientCollection = {
|
||||
[node: string]: Client;
|
||||
};
|
||||
type EnvState = {
|
||||
nodes: string[];
|
||||
status: 'default' | 'pending' | 'success' | 'failed';
|
||||
error: string | null;
|
||||
};
|
||||
type Actions = {
|
||||
setUrl: (url: string) => void;
|
||||
initialize: () => Promise<void>;
|
||||
};
|
||||
export type Env = Environment & EnvState;
|
||||
export type EnvStore = Env & Actions;
|
||||
|
||||
const STORAGE_KEY = 'vega_url';
|
||||
const SUBSCRIPTION_TIMEOUT = 3000;
|
||||
|
||||
export const useEnvironment = create<EnvStore>((set, get) => ({
|
||||
...compileEnvVars(),
|
||||
nodes: [],
|
||||
status: 'default',
|
||||
error: null,
|
||||
setUrl: (url) => {
|
||||
set({ VEGA_URL: url, status: 'success', error: null });
|
||||
LocalStorage.setItem(STORAGE_KEY, url);
|
||||
},
|
||||
initialize: async () => {
|
||||
set({ status: 'pending' });
|
||||
|
||||
// validate env vars
|
||||
try {
|
||||
const rawVars = compileEnvVars();
|
||||
const safeVars = envSchema.parse(rawVars);
|
||||
set({ ...safeVars });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
const headline = t('Error processing the Vega environment');
|
||||
set({
|
||||
status: 'failed',
|
||||
error: headline,
|
||||
});
|
||||
console.error(compileErrors(headline, err));
|
||||
return;
|
||||
}
|
||||
|
||||
const state = get();
|
||||
const storedUrl = LocalStorage.getItem(STORAGE_KEY);
|
||||
|
||||
let nodes: string[] | undefined;
|
||||
try {
|
||||
nodes = await fetchConfig(state.VEGA_CONFIG_URL);
|
||||
set({ nodes });
|
||||
} catch (err) {
|
||||
console.warn(`Could not fetch node config from ${state.VEGA_CONFIG_URL}`);
|
||||
}
|
||||
|
||||
// Node url found in localStorage, if its valid attempt to connect
|
||||
if (storedUrl) {
|
||||
if (isValidUrl(storedUrl)) {
|
||||
set({ VEGA_URL: storedUrl, status: 'success' });
|
||||
return;
|
||||
} else {
|
||||
LocalStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// VEGA_URL env var is set and is a valid url no need to proceed
|
||||
if (state.VEGA_URL) {
|
||||
set({ status: 'success' });
|
||||
return;
|
||||
}
|
||||
|
||||
// No url found in env vars or localStorage, AND no nodes were found in
|
||||
// the config fetched from VEGA_CONFIG_URL, app initialization has failed
|
||||
if (!nodes || !nodes.length) {
|
||||
set({
|
||||
status: 'failed',
|
||||
error: t(`Failed to fetch node config from ${state.VEGA_CONFIG_URL}`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a map of node urls to client instances
|
||||
const clients: ClientCollection = {};
|
||||
nodes.forEach((url) => {
|
||||
clients[url] = createClient({
|
||||
url,
|
||||
cacheConfig: undefined,
|
||||
retry: false,
|
||||
connectToDevTools: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Find a suitable node to connect to by attempting a query and a
|
||||
// subscription, first to fulfill both will be the resulting url.
|
||||
const url = await findNode(clients);
|
||||
|
||||
if (url !== null) {
|
||||
set({
|
||||
status: 'success',
|
||||
VEGA_URL: url,
|
||||
});
|
||||
LocalStorage.setItem(STORAGE_KEY, url);
|
||||
}
|
||||
// Every node failed either to make a query or retrieve data from
|
||||
// a subscription
|
||||
else {
|
||||
set({
|
||||
status: 'failed',
|
||||
error: t('No node found'),
|
||||
});
|
||||
console.warn(t('No suitable vega node was found'));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Initialize Vega app to dynamically select a node from the
|
||||
* VEGA_CONFIG_URL
|
||||
*
|
||||
* This can be ommitted if you intend to only use a single node,
|
||||
* in those cases be sure to set NX_VEGA_URL
|
||||
*/
|
||||
export const useInitializeEnv = () => {
|
||||
const { initialize, status } = useEnvironment((store) => ({
|
||||
status: store.status,
|
||||
initialize: store.initialize,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'default') {
|
||||
initialize();
|
||||
}
|
||||
}, [status, initialize]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch and validate a vega node configuration
|
||||
*/
|
||||
const fetchConfig = async (url?: string) => {
|
||||
if (!url) return [];
|
||||
const res = await fetch(url);
|
||||
const cfg = await res.json();
|
||||
const result = configSchema.parse(cfg);
|
||||
return result.hosts;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a suitable node by running a test query and test
|
||||
* subscription, against a list of clients, first to resolve wins
|
||||
*/
|
||||
const findNode = (clients: ClientCollection): Promise<string | null> => {
|
||||
const tests = Object.entries(clients).map((args) => testNode(...args));
|
||||
return Promise.race(tests);
|
||||
};
|
||||
|
||||
/**
|
||||
* Test a node for suitability for connection
|
||||
*/
|
||||
const testNode = async (
|
||||
url: string,
|
||||
client: Client
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const results = await Promise.all([
|
||||
testQuery(client),
|
||||
testSubscription(client),
|
||||
]);
|
||||
if (results[0] && results[1]) {
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn(`Tests failed for ${url}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a test query on a client
|
||||
*/
|
||||
const testQuery = async (client: Client) => {
|
||||
try {
|
||||
const result = await client.query<StatisticsQuery>({
|
||||
query: StatisticsDocument,
|
||||
});
|
||||
if (!result || result.error) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a test subscription on a client. A subscription
|
||||
* that takes longer than SUBSCRIPTION_TIMEOUT ms to respond
|
||||
* is deemed a failure
|
||||
*/
|
||||
const testSubscription = (client: Client) => {
|
||||
return new Promise((resolve) => {
|
||||
const sub = client
|
||||
.subscribe<BlockTimeSubscription>({
|
||||
query: BlockTimeDocument,
|
||||
errorPolicy: 'all',
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
resolve(true);
|
||||
sub.unsubscribe();
|
||||
},
|
||||
error: () => {
|
||||
resolve(false);
|
||||
sub.unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
resolve(false);
|
||||
sub.unsubscribe();
|
||||
}, SUBSCRIPTION_TIMEOUT);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve env vars, parsing where needed some type casting is needed
|
||||
* here to appease the environment store interface
|
||||
*/
|
||||
function compileEnvVars() {
|
||||
const VEGA_ENV = process.env['NX_VEGA_ENV'] as Networks;
|
||||
const env: Environment = {
|
||||
VEGA_URL: process.env['NX_VEGA_URL'],
|
||||
VEGA_ENV,
|
||||
VEGA_CONFIG_URL: process.env['NX_VEGA_CONFIG_URL'] as string,
|
||||
VEGA_NETWORKS: parseNetworks(process.env['NX_VEGA_NETWORKS']),
|
||||
VEGA_WALLET_URL: process.env['NX_VEGA_WALLET_URL'] as string,
|
||||
HOSTED_WALLET_URL: process.env['NX_HOSTED_WALLET_URL'],
|
||||
ETHERSCAN_URL: getEtherscanUrl(VEGA_ENV, process.env['NX_ETHERSCAN_URL']),
|
||||
ETHEREUM_PROVIDER_URL: getEthereumProviderUrl(
|
||||
VEGA_ENV,
|
||||
process.env['NX_ETHEREUM_PROVIDER_URL']
|
||||
),
|
||||
ETH_LOCAL_PROVIDER_URL: process.env['NX_ETH_LOCAL_PROVIDER_URL'],
|
||||
ETH_WALLET_MNEMONIC: process.env['NX_ETH_WALLET_MNEMONIC'],
|
||||
VEGA_DOCS_URL: process.env['NX_VEGA_DOCS_URL'],
|
||||
VEGA_EXPLORER_URL: process.env['NX_VEGA_EXPLORER_URL'],
|
||||
VEGA_TOKEN_URL: process.env['NX_VEGA_TOKEN_URL'],
|
||||
GITHUB_FEEDBACK_URL: process.env['NX_GITHUB_FEEDBACK_URL'],
|
||||
MAINTENANCE_PAGE: parseBoolean(process.env['NX_MAINTENANCE_PAGE']),
|
||||
GIT_BRANCH: process.env['GIT_COMMIT_BRANCH'],
|
||||
GIT_COMMIT_HASH: process.env['GIT_COMMIT_HASH'],
|
||||
GIT_ORIGIN_URL: process.env['GIT_ORIGIN_URL'],
|
||||
};
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseNetworks(value?: string) {
|
||||
if (value) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function parseBoolean(value?: string) {
|
||||
return ['true', '1', 'yes'].includes(value?.toLowerCase() || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a fallback ethereum provider url for test purposes in some apps
|
||||
*/
|
||||
function getEthereumProviderUrl(
|
||||
network: Networks | undefined,
|
||||
envvar: string | undefined
|
||||
) {
|
||||
if (envvar) return envvar;
|
||||
return network === Networks.MAINNET
|
||||
? 'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'
|
||||
: 'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8';
|
||||
}
|
||||
/**
|
||||
* Provide a fallback etherscan url for test purposes in some apps
|
||||
*/
|
||||
function getEtherscanUrl(
|
||||
network: Networks | undefined,
|
||||
envvar: string | undefined
|
||||
) {
|
||||
if (envvar) return envvar;
|
||||
return network === Networks.MAINNET
|
||||
? 'https://etherscan.io'
|
||||
: 'https://sepolia.etherscan.io';
|
||||
}
|
@ -1,181 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { MaintenancePage } from '@vegaprotocol/ui-toolkit';
|
||||
import { NodeSwitcherDialog } from '../components/node-switcher-dialog';
|
||||
import { useConfig } from './use-config';
|
||||
import { useNodes } from './use-nodes';
|
||||
import { compileEnvironment } from '../utils/compile-environment';
|
||||
import { validateEnvironment } from '../utils/validate-environment';
|
||||
import {
|
||||
getErrorType,
|
||||
getErrorByType,
|
||||
getIsNodeLoading,
|
||||
} from '../utils/validate-node';
|
||||
import { ErrorType } from '../types';
|
||||
import type {
|
||||
Environment,
|
||||
Networks,
|
||||
RawEnvironment,
|
||||
NodeData,
|
||||
Configuration,
|
||||
} from '../types';
|
||||
import { useNodeHealth } from './use-node-health';
|
||||
|
||||
type EnvironmentProviderProps = {
|
||||
config?: Configuration;
|
||||
definitions?: Partial<RawEnvironment>;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export type EnvironmentState = Environment & {
|
||||
configLoading: boolean;
|
||||
networkError?: ErrorType;
|
||||
blockDifference: number;
|
||||
nodeSwitcherOpen: boolean;
|
||||
setNodeSwitcherOpen: () => void;
|
||||
};
|
||||
|
||||
const EnvironmentContext = createContext({} as EnvironmentState);
|
||||
|
||||
const hasLoaded = (env: Networks, node: NodeData) =>
|
||||
node.initialized &&
|
||||
!getIsNodeLoading(node) &&
|
||||
getErrorType(env, node) === null;
|
||||
|
||||
const hasFailedLoading = (env: Networks, node: NodeData) =>
|
||||
node.initialized &&
|
||||
!getIsNodeLoading(node) &&
|
||||
getErrorType(env, node) !== null;
|
||||
|
||||
export const EnvironmentProvider = ({
|
||||
config: defaultConfig,
|
||||
definitions,
|
||||
children,
|
||||
}: EnvironmentProviderProps) => {
|
||||
const [networkError, setNetworkError] = useState<undefined | ErrorType>();
|
||||
const [isNodeSwitcherOpen, setNodeSwitcherIsOpen] = useState(false);
|
||||
const [environment, updateEnvironment] = useState<Environment>(
|
||||
compileEnvironment(definitions)
|
||||
);
|
||||
const setNodeSwitcherOpen = useCallback((isOpen: boolean) => {
|
||||
if (!('Cypress' in window)) {
|
||||
setNodeSwitcherIsOpen(isOpen);
|
||||
}
|
||||
}, []);
|
||||
const { loading, config } = useConfig(
|
||||
{ environment, defaultConfig },
|
||||
(errorType) => {
|
||||
if (!environment.VEGA_URL) {
|
||||
setNetworkError(errorType);
|
||||
setNodeSwitcherOpen(true);
|
||||
} else {
|
||||
const error = getErrorByType(errorType, environment.VEGA_ENV);
|
||||
error && console.warn(error.headline);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const { state: nodes, clients } = useNodes(
|
||||
config,
|
||||
environment.MAINTENANCE_PAGE
|
||||
);
|
||||
|
||||
const blockDifference = useNodeHealth(clients, environment.VEGA_URL);
|
||||
|
||||
const nodeKeys = Object.keys(nodes);
|
||||
|
||||
useEffect(() => {
|
||||
if (!environment.VEGA_URL) {
|
||||
const successfulNodeKey = nodeKeys.find((key) =>
|
||||
hasLoaded(environment.VEGA_ENV, nodes[key])
|
||||
);
|
||||
if (successfulNodeKey && nodes[successfulNodeKey]) {
|
||||
Object.keys(clients).forEach((node) => clients[node]?.stop());
|
||||
const url = nodes[successfulNodeKey].url;
|
||||
updateEnvironment((prevEnvironment) => ({
|
||||
...prevEnvironment,
|
||||
VEGA_URL: url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// if the selected node has errors
|
||||
if (environment.VEGA_URL && nodes[environment.VEGA_URL]) {
|
||||
const errorType = getErrorType(
|
||||
environment.VEGA_ENV,
|
||||
nodes[environment.VEGA_URL]
|
||||
);
|
||||
if (errorType !== null) {
|
||||
Object.keys(clients).forEach((node) => clients[node]?.stop());
|
||||
setNetworkError(errorType);
|
||||
setNodeSwitcherOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if the config doesn't contain nodes the app can connect to
|
||||
if (
|
||||
nodeKeys.length > 0 &&
|
||||
nodeKeys.filter((key) =>
|
||||
hasFailedLoading(environment.VEGA_ENV, nodes[key])
|
||||
).length === nodeKeys.length
|
||||
) {
|
||||
Object.keys(clients).forEach((node) => clients[node]?.stop());
|
||||
setNetworkError(ErrorType.CONNECTION_ERROR_ALL);
|
||||
setNodeSwitcherOpen(true);
|
||||
}
|
||||
// prevent infinite render loop by skipping deps which will change as a result
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [environment.VEGA_URL, nodes]);
|
||||
|
||||
const errorMessage = validateEnvironment(environment);
|
||||
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (environment.MAINTENANCE_PAGE) {
|
||||
return <MaintenancePage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<EnvironmentContext.Provider
|
||||
value={{
|
||||
...environment,
|
||||
configLoading: loading,
|
||||
networkError,
|
||||
blockDifference,
|
||||
nodeSwitcherOpen: isNodeSwitcherOpen,
|
||||
setNodeSwitcherOpen: () => setNodeSwitcherOpen(true),
|
||||
}}
|
||||
>
|
||||
<NodeSwitcherDialog
|
||||
dialogOpen={isNodeSwitcherOpen}
|
||||
initialErrorType={networkError}
|
||||
setDialogOpen={setNodeSwitcherOpen}
|
||||
loading={loading}
|
||||
config={config}
|
||||
onConnect={(url) => {
|
||||
updateEnvironment((env) => ({ ...env, VEGA_URL: url }));
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</EnvironmentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useEnvironment = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'Error running "useEnvironment". No context found, make sure your component is wrapped in an <EnvironmentProvider />.'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
@ -1,117 +1,114 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -1,366 +0,0 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { ApolloClient } from '@apollo/client';
|
||||
import { createClient } from '@vegaprotocol/apollo-client';
|
||||
import { useNodes } from './use-nodes';
|
||||
import createMockClient, {
|
||||
getMockStatisticsResult,
|
||||
} from './mocks/apollo-client';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
jest.mock('@vegaprotocol/apollo-client');
|
||||
|
||||
const MOCK_DURATION = 1073;
|
||||
|
||||
const initialState = {
|
||||
url: '',
|
||||
initialized: false,
|
||||
responseTime: {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: undefined,
|
||||
},
|
||||
block: {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: undefined,
|
||||
},
|
||||
subscription: {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: undefined,
|
||||
},
|
||||
chain: {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
window.performance.getEntriesByName = jest
|
||||
.fn()
|
||||
.mockImplementation((url: string) => [
|
||||
{
|
||||
entryType: 'resource',
|
||||
name: url,
|
||||
startTime: 0,
|
||||
toJSON: () => ({}),
|
||||
duration: MOCK_DURATION,
|
||||
},
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation(() => createMockClient());
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useNodes hook', () => {
|
||||
it('returns the default state when empty config provided', () => {
|
||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
||||
|
||||
expect(result.current.state).toEqual({});
|
||||
});
|
||||
|
||||
it('sets loading state while waiting for the results', async () => {
|
||||
const node = 'https://some.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
||||
|
||||
expect(result.current.state[node]).toEqual({
|
||||
...initialState,
|
||||
url: node,
|
||||
initialized: true,
|
||||
responseTime: {
|
||||
...initialState.responseTime,
|
||||
isLoading: true,
|
||||
},
|
||||
block: {
|
||||
...initialState.block,
|
||||
isLoading: true,
|
||||
},
|
||||
chain: {
|
||||
...initialState.chain,
|
||||
isLoading: true,
|
||||
},
|
||||
subscription: {
|
||||
...initialState.subscription,
|
||||
isLoading: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sets statistics results', async () => {
|
||||
const mockResult = getMockStatisticsResult();
|
||||
const node = 'https://some.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].block).toEqual({
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: Number(mockResult.statistics.blockHeight),
|
||||
});
|
||||
|
||||
expect(result.current.state[node].chain).toEqual({
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: mockResult.statistics.chainId,
|
||||
});
|
||||
|
||||
expect(result.current.state[node].responseTime).toEqual({
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: MOCK_DURATION,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('sets subscription result', async () => {
|
||||
const node = 'https://some.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].subscription).toEqual({
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('sets error when host in not a valid url', async () => {
|
||||
const node = 'not-url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].block.hasError).toBe(true);
|
||||
expect(result.current.state[node].chain.hasError).toBe(true);
|
||||
expect(result.current.state[node].responseTime.hasError).toBe(true);
|
||||
expect(result.current.state[node].responseTime.hasError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets error when statistics request fails', async () => {
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation(() =>
|
||||
createMockClient({ statistics: { hasError: true } })
|
||||
);
|
||||
|
||||
const node = 'https://some.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].block).toEqual({
|
||||
isLoading: false,
|
||||
hasError: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
expect(result.current.state[node].chain).toEqual({
|
||||
isLoading: false,
|
||||
hasError: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
expect(result.current.state[node].responseTime).toEqual({
|
||||
isLoading: false,
|
||||
hasError: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('sets error when subscription request fails', async () => {
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation(() =>
|
||||
createMockClient({ busEvents: { hasError: true } })
|
||||
);
|
||||
|
||||
const node = 'https://some.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].subscription).toEqual({
|
||||
isLoading: false,
|
||||
hasError: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('allows updating block values', async () => {
|
||||
const mockResult = getMockStatisticsResult();
|
||||
const node = 'https://some.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].block.value).toEqual(
|
||||
Number(mockResult.statistics.blockHeight)
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateNodeBlock(node, 12);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].block.value).toEqual(12);
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing when calling the block update on a non-existing node', async () => {
|
||||
const mockResult = getMockStatisticsResult();
|
||||
const node = 'https://some.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].block.value).toEqual(
|
||||
Number(mockResult.statistics.blockHeight)
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateNodeBlock('https://non-existing.url', 12);
|
||||
});
|
||||
|
||||
expect(result.current.state['https://non-existing.url']).toBe(undefined);
|
||||
});
|
||||
|
||||
it('adds new node', async () => {
|
||||
const node = 'custom-node-key';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
||||
|
||||
expect(result.current.state[node]).toEqual(undefined);
|
||||
|
||||
act(() => {
|
||||
result.current.addNode(node);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node]).toEqual(initialState);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets new url for node', async () => {
|
||||
const node = 'https://some.url';
|
||||
const newUrl = 'https://some-other.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [node] }));
|
||||
|
||||
act(() => {
|
||||
result.current.updateNodeUrl(node, newUrl);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].url).toBe(newUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets error when custom node has an invalid url', async () => {
|
||||
const node = 'node-key';
|
||||
const url = 'not-url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
||||
|
||||
expect(result.current.state[node]).toBe(undefined);
|
||||
|
||||
act(() => {
|
||||
result.current.updateNodeUrl(node, url);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].url).toBe(url);
|
||||
expect(result.current.state[node].block.hasError).toBe(true);
|
||||
expect(result.current.state[node].chain.hasError).toBe(true);
|
||||
expect(result.current.state[node].responseTime.hasError).toBe(true);
|
||||
expect(result.current.state[node].subscription.hasError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets error when custom node statistics request fails', async () => {
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation(() =>
|
||||
createMockClient({ statistics: { hasError: true } })
|
||||
);
|
||||
|
||||
const node = 'node-key';
|
||||
const url = 'https://some.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
||||
|
||||
expect(result.current.state[node]).toBe(undefined);
|
||||
|
||||
act(() => {
|
||||
result.current.updateNodeUrl(node, url);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].block).toEqual({
|
||||
isLoading: false,
|
||||
hasError: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
expect(result.current.state[node].chain).toEqual({
|
||||
isLoading: false,
|
||||
hasError: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
expect(result.current.state[node].responseTime).toEqual({
|
||||
isLoading: false,
|
||||
hasError: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('sets error when custom node subscription fails', async () => {
|
||||
// @ts-ignore allow adding a mock return value to mocked module
|
||||
createClient.mockImplementation(() =>
|
||||
createMockClient({ busEvents: { hasError: true } })
|
||||
);
|
||||
|
||||
const node = 'node-key';
|
||||
const url = 'https://some.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
||||
|
||||
expect(result.current.state[node]).toBe(undefined);
|
||||
|
||||
act(() => {
|
||||
result.current.updateNodeUrl(node, url);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state[node].subscription).toEqual({
|
||||
isLoading: false,
|
||||
hasError: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes a collection of clients', async () => {
|
||||
const url1 = 'https://some.url';
|
||||
const url2 = 'https://some-other.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [url1, url2] }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.clients[url1]).toBeInstanceOf(ApolloClient);
|
||||
expect(result.current.clients[url2]).toBeInstanceOf(ApolloClient);
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes a client for the custom node', async () => {
|
||||
const node = 'node-key';
|
||||
const url = 'https://some.url';
|
||||
const { result } = renderHook(() => useNodes({ hosts: [] }));
|
||||
|
||||
act(() => {
|
||||
result.current.updateNodeUrl(node, url);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.clients[url]).toBeInstanceOf(ApolloClient);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,253 +0,0 @@
|
||||
import type { Dispatch } from 'react';
|
||||
import { useState, useEffect, useReducer } from 'react';
|
||||
import { produce } from 'immer';
|
||||
import { initializeNode } from '../utils/initialize-node';
|
||||
import type { NodeData, Configuration } from '../types';
|
||||
import type { createClient } from '@vegaprotocol/apollo-client';
|
||||
|
||||
type StatisticsPayload = {
|
||||
block: NodeData['block']['value'];
|
||||
chain: NodeData['chain']['value'];
|
||||
responseTime: NodeData['responseTime']['value'];
|
||||
};
|
||||
|
||||
export enum ACTIONS {
|
||||
GET_STATISTICS,
|
||||
GET_STATISTICS_SUCCESS,
|
||||
GET_STATISTICS_FAILURE,
|
||||
CHECK_SUBSCRIPTION,
|
||||
CHECK_SUBSCRIPTION_SUCCESS,
|
||||
CHECK_SUBSCRIPTION_FAILURE,
|
||||
ADD_NODE,
|
||||
UPDATE_NODE_URL,
|
||||
UPDATE_NODE_BLOCK,
|
||||
}
|
||||
|
||||
type ActionType<T extends ACTIONS, P = undefined> = {
|
||||
type: T;
|
||||
node: string;
|
||||
payload?: P;
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| ActionType<ACTIONS.GET_STATISTICS, { url: string }>
|
||||
| ActionType<ACTIONS.GET_STATISTICS_SUCCESS, StatisticsPayload>
|
||||
| ActionType<ACTIONS.GET_STATISTICS_FAILURE>
|
||||
| ActionType<ACTIONS.CHECK_SUBSCRIPTION, { url: string }>
|
||||
| ActionType<ACTIONS.CHECK_SUBSCRIPTION_SUCCESS>
|
||||
| ActionType<ACTIONS.CHECK_SUBSCRIPTION_FAILURE>
|
||||
| ActionType<ACTIONS.ADD_NODE>
|
||||
| ActionType<ACTIONS.UPDATE_NODE_URL, { url: string }>
|
||||
| ActionType<ACTIONS.UPDATE_NODE_BLOCK, number>;
|
||||
|
||||
function withData<T>(value?: T) {
|
||||
return {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
function withError<T>(value?: T) {
|
||||
return {
|
||||
isLoading: false,
|
||||
hasError: true,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
const getNodeData = (url?: string): NodeData => ({
|
||||
url: url ?? '',
|
||||
initialized: false,
|
||||
responseTime: withData(),
|
||||
block: withData(),
|
||||
subscription: withData(),
|
||||
chain: withData(),
|
||||
});
|
||||
|
||||
const getInitialState = (config?: Configuration) =>
|
||||
(config?.hosts ?? []).reduce<Record<string, NodeData>>(
|
||||
(acc, url) => ({
|
||||
...acc,
|
||||
[url]: getNodeData(url),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
export type ClientCollection = Record<
|
||||
string,
|
||||
undefined | ReturnType<typeof createClient>
|
||||
>;
|
||||
|
||||
type ClientData = {
|
||||
clients: ClientCollection;
|
||||
subscriptions: ReturnType<typeof initializeNode>['unsubscribe'][];
|
||||
};
|
||||
|
||||
const initializeNodes = (
|
||||
dispatch: Dispatch<Action>,
|
||||
nodes: Record<string, string>
|
||||
) => {
|
||||
return Object.keys(nodes).reduce<ClientData>(
|
||||
(acc, node) => {
|
||||
const { client, unsubscribe } = initializeNode(
|
||||
dispatch,
|
||||
node,
|
||||
nodes[node]
|
||||
);
|
||||
Object.assign(acc.clients, { [nodes[node]]: client });
|
||||
acc.subscriptions.push(unsubscribe);
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
clients: {},
|
||||
subscriptions: [],
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const reducer = (state: Record<string, NodeData>, action: Action) => {
|
||||
switch (action.type) {
|
||||
case ACTIONS.GET_STATISTICS:
|
||||
return produce(state, (state) => {
|
||||
if (!state[action.node]) {
|
||||
state[action.node] = getNodeData(action.payload?.url);
|
||||
}
|
||||
state[action.node].url = action.payload?.url ?? '';
|
||||
state[action.node].initialized = true;
|
||||
state[action.node].block.isLoading = true;
|
||||
state[action.node].chain.isLoading = true;
|
||||
state[action.node].responseTime.isLoading = true;
|
||||
});
|
||||
case ACTIONS.GET_STATISTICS_SUCCESS:
|
||||
return produce(state, (state) => {
|
||||
if (!state[action.node]) return;
|
||||
state[action.node].block = withData(action.payload?.block);
|
||||
state[action.node].chain = withData(action.payload?.chain);
|
||||
state[action.node].responseTime = withData(
|
||||
action.payload?.responseTime
|
||||
);
|
||||
});
|
||||
case ACTIONS.GET_STATISTICS_FAILURE:
|
||||
return produce(state, (state) => {
|
||||
if (!state[action.node]) return;
|
||||
state[action.node].block = withError();
|
||||
state[action.node].chain = withError();
|
||||
state[action.node].responseTime = withError();
|
||||
});
|
||||
case ACTIONS.CHECK_SUBSCRIPTION:
|
||||
return produce(state, (state) => {
|
||||
if (!state[action.node]) {
|
||||
state[action.node] = getNodeData(action.payload?.url);
|
||||
}
|
||||
state[action.node].url = action.payload?.url ?? '';
|
||||
state[action.node].subscription.isLoading = true;
|
||||
state[action.node].initialized = true;
|
||||
});
|
||||
case ACTIONS.CHECK_SUBSCRIPTION_SUCCESS:
|
||||
return produce(state, (state) => {
|
||||
if (!state[action.node]) return;
|
||||
state[action.node].subscription = withData(true);
|
||||
});
|
||||
case ACTIONS.CHECK_SUBSCRIPTION_FAILURE:
|
||||
return produce(state, (state) => {
|
||||
if (!state[action.node]) return;
|
||||
state[action.node].subscription = withError();
|
||||
});
|
||||
case ACTIONS.ADD_NODE:
|
||||
return produce(state, (state) => {
|
||||
state[action.node] = getNodeData();
|
||||
});
|
||||
case ACTIONS.UPDATE_NODE_URL:
|
||||
return produce(state, (state) => {
|
||||
const existingNode = Object.keys(state).find(
|
||||
(node) =>
|
||||
action.node !== node && state[node].url === action.payload?.url
|
||||
);
|
||||
state[action.node] = existingNode
|
||||
? state[existingNode]
|
||||
: getNodeData(action.payload?.url);
|
||||
});
|
||||
case ACTIONS.UPDATE_NODE_BLOCK:
|
||||
return produce(state, (state) => {
|
||||
if (!state[action.node]) return;
|
||||
state[action.node].block.value = action.payload;
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests each node to see if its suitable for connecting to and returns that data
|
||||
* as a map of node urls to an object of that data
|
||||
*/
|
||||
export const useNodes = (config?: Configuration, skip?: boolean) => {
|
||||
const [clients, setClients] = useState<ClientCollection>({});
|
||||
const [state, dispatch] = useReducer(reducer, getInitialState(config));
|
||||
const configCacheKey = config?.hosts.join(';');
|
||||
const allUrls = Object.keys(state).map((node) => state[node].url);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.keys(clients).forEach((url) => clients[url]?.stop());
|
||||
};
|
||||
// stop all created clients on unmount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hosts = skip ? [] : config?.hosts || [];
|
||||
const nodeUrlMap = hosts.reduce((acc, url) => ({ ...acc, [url]: url }), {});
|
||||
const { clients: newClients, subscriptions } = initializeNodes(
|
||||
dispatch,
|
||||
nodeUrlMap
|
||||
);
|
||||
setClients(newClients);
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
// use primitive cache key to prevent infinite rerender loop
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [configCacheKey, skip]);
|
||||
|
||||
useEffect(() => {
|
||||
const allNodes = Object.keys(state);
|
||||
const initializedUrls = Object.keys(clients);
|
||||
const nodeUrlMap = allUrls
|
||||
.filter((node) => !initializedUrls.includes(node))
|
||||
.reduce<Record<string, string>>((acc, url) => {
|
||||
const node = allNodes.find((key) => state[key].url === url);
|
||||
if (node) {
|
||||
acc[node] = url;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const { clients: newClients, subscriptions } = initializeNodes(
|
||||
dispatch,
|
||||
nodeUrlMap
|
||||
);
|
||||
setClients((prevClients) => ({
|
||||
...prevClients,
|
||||
...newClients,
|
||||
}));
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
// use primitive cache key to prevent infinite rerender loop
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allUrls.join(';')]);
|
||||
|
||||
return {
|
||||
state,
|
||||
clients,
|
||||
addNode: (node: string) => dispatch({ type: ACTIONS.ADD_NODE, node }),
|
||||
updateNodeUrl: (node: string, url: string) =>
|
||||
dispatch({ type: ACTIONS.UPDATE_NODE_URL, node, payload: { url } }),
|
||||
updateNodeBlock: (node: string, value: number) =>
|
||||
dispatch({ type: ACTIONS.UPDATE_NODE_BLOCK, node, payload: value }),
|
||||
};
|
||||
};
|
@ -1,44 +1,17 @@
|
||||
import type z from 'zod';
|
||||
|
||||
import type { 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;
|
||||
|
@ -2,6 +2,7 @@ query Statistics {
|
||||
statistics {
|
||||
chainId
|
||||
blockHeight
|
||||
vegaTime
|
||||
}
|
||||
}
|
||||
|
||||
|
3
libs/environment/src/utils/__generated__/Node.ts
generated
3
libs/environment/src/utils/__generated__/Node.ts
generated
@ -6,7 +6,7 @@ const defaultOptions = {} as const;
|
||||
export type StatisticsQueryVariables = Types.Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -1,140 +0,0 @@
|
||||
import type { RawEnvironment, EnvKey, Environment } from '../types';
|
||||
import { Networks, ENV_KEYS } from '../types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_env_?: Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
const getDefaultEtherumProviderUrl = (env: Networks) => {
|
||||
return env === Networks.MAINNET
|
||||
? 'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'
|
||||
: 'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8';
|
||||
};
|
||||
|
||||
const getDefaultEtherscanUrl = (env: Networks) => {
|
||||
return env === Networks.MAINNET
|
||||
? 'https://etherscan.io'
|
||||
: 'https://sepolia.etherscan.io';
|
||||
};
|
||||
|
||||
const transformValue = (key: EnvKey, value?: string) => {
|
||||
switch (key) {
|
||||
case 'VEGA_ENV':
|
||||
return value as Networks;
|
||||
case 'VEGA_NETWORKS': {
|
||||
if (value) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'Error parsing the "NX_VEGA_NETWORKS" environment variable. Make sure it has a valid JSON format.'
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
case 'MAINTENANCE_PAGE':
|
||||
return ['true', '1', 'yes'].includes(value?.toLowerCase() || '');
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const getBundledEnvironmentValue = (key: EnvKey) => {
|
||||
switch (key) {
|
||||
// need to have these hardcoded so on build time they can be replaced with the relevant environment variable
|
||||
case 'VEGA_URL':
|
||||
return process.env['NX_VEGA_URL'];
|
||||
case 'VEGA_ENV':
|
||||
return process.env['NX_VEGA_ENV'];
|
||||
case 'VEGA_CONFIG_URL':
|
||||
return process.env['NX_VEGA_CONFIG_URL'];
|
||||
case 'ETHEREUM_PROVIDER_URL':
|
||||
return process.env['NX_ETHEREUM_PROVIDER_URL'];
|
||||
case 'ETHERSCAN_URL':
|
||||
return process.env['NX_ETHERSCAN_URL'];
|
||||
case 'VEGA_NETWORKS':
|
||||
return process.env['NX_VEGA_NETWORKS'];
|
||||
case 'GIT_BRANCH':
|
||||
return process.env['NX_GIT_BRANCH'];
|
||||
case 'GIT_COMMIT_HASH':
|
||||
return process.env['NX_GIT_COMMIT_HASH'];
|
||||
case 'GIT_ORIGIN_URL':
|
||||
return process.env['NX_GIT_ORIGIN_URL'];
|
||||
case 'GITHUB_FEEDBACK_URL':
|
||||
return process.env['NX_GITHUB_FEEDBACK_URL'];
|
||||
case 'VEGA_EXPLORER_URL':
|
||||
return process.env['NX_VEGA_EXPLORER_URL'];
|
||||
case 'VEGA_WALLET_URL':
|
||||
return process.env['NX_VEGA_WALLET_URL'];
|
||||
case 'VEGA_TOKEN_URL':
|
||||
return process.env['NX_VEGA_TOKEN_URL'];
|
||||
case 'VEGA_DOCS_URL':
|
||||
return process.env['NX_VEGA_DOCS_URL'];
|
||||
case 'HOSTED_WALLET_URL':
|
||||
return process.env['NX_HOSTED_WALLET_URL'];
|
||||
case 'ETH_LOCAL_PROVIDER_URL':
|
||||
return process.env['NX_ETH_LOCAL_PROVIDER_URL'];
|
||||
case 'ETH_WALLET_MNEMONIC':
|
||||
return process.env['NX_ETH_WALLET_MNEMONIC'];
|
||||
case 'MAINTENANCE_PAGE':
|
||||
return process.env['NX_MAINTENANCE_PAGE'];
|
||||
}
|
||||
};
|
||||
|
||||
const getValue = (key: EnvKey, definitions: Partial<RawEnvironment> = {}) => {
|
||||
if (!isBrowser) {
|
||||
return transformValue(
|
||||
key,
|
||||
definitions[key] ?? getBundledEnvironmentValue(key)
|
||||
);
|
||||
}
|
||||
return transformValue(
|
||||
key,
|
||||
definitions[key] ??
|
||||
window._env_?.[`NX_${key}`] ??
|
||||
getBundledEnvironmentValue(key)
|
||||
);
|
||||
};
|
||||
|
||||
export const compileEnvironment = (
|
||||
definitions?: Partial<RawEnvironment>
|
||||
): Environment => {
|
||||
const environment = ENV_KEYS.reduce((acc, key) => {
|
||||
const value = getValue(key, definitions);
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
return {
|
||||
...acc,
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as Environment);
|
||||
|
||||
const networkOverride = environment.VEGA_ENV
|
||||
? {
|
||||
[environment.VEGA_ENV]: isBrowser ? window.location.origin : undefined,
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
// @ts-ignore enable using default object props
|
||||
ETHERSCAN_URL: getDefaultEtherscanUrl(environment['VEGA_ENV']),
|
||||
// @ts-ignore enable using default object props
|
||||
ETHEREUM_PROVIDER_URL: getDefaultEtherumProviderUrl(
|
||||
environment['VEGA_ENV']
|
||||
),
|
||||
...environment,
|
||||
VEGA_NETWORKS: {
|
||||
...networkOverride,
|
||||
...environment.VEGA_NETWORKS,
|
||||
},
|
||||
};
|
||||
};
|
@ -1,19 +1,32 @@
|
||||
import type { ZodIssue } from 'zod';
|
||||
import { 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}`;
|
||||
}
|
||||
};
|
||||
|
@ -1,55 +0,0 @@
|
||||
import type { Dispatch } from 'react';
|
||||
import { ACTIONS } from '../hooks/use-nodes';
|
||||
import type { Action } from '../hooks/use-nodes';
|
||||
import { requestNode } from './request-node';
|
||||
|
||||
const getResponseTime = (url: string) => {
|
||||
const requestUrl = new URL(url);
|
||||
const requests = window.performance.getEntriesByName(requestUrl.href);
|
||||
const { duration } = (requests.length && requests[requests.length - 1]) || {};
|
||||
return duration;
|
||||
};
|
||||
|
||||
export const initializeNode = (
|
||||
dispatch: Dispatch<Action>,
|
||||
node: string,
|
||||
nodeUrl?: string
|
||||
) => {
|
||||
let isMounted = true;
|
||||
const url = nodeUrl ?? node;
|
||||
|
||||
dispatch({ type: ACTIONS.GET_STATISTICS, node, payload: { url } });
|
||||
dispatch({ type: ACTIONS.CHECK_SUBSCRIPTION, node, payload: { url } });
|
||||
|
||||
const client = requestNode(url, {
|
||||
onStatsSuccess: (data) => {
|
||||
isMounted &&
|
||||
dispatch({
|
||||
type: ACTIONS.GET_STATISTICS_SUCCESS,
|
||||
node,
|
||||
payload: {
|
||||
chain: data.statistics.chainId,
|
||||
block: Number(data.statistics.blockHeight),
|
||||
responseTime: getResponseTime(url),
|
||||
},
|
||||
});
|
||||
},
|
||||
onStatsFailure: () => {
|
||||
isMounted && dispatch({ type: ACTIONS.GET_STATISTICS_FAILURE, node });
|
||||
},
|
||||
onSubscriptionSuccess: () => {
|
||||
isMounted && dispatch({ type: ACTIONS.CHECK_SUBSCRIPTION_SUCCESS, node });
|
||||
},
|
||||
onSubscriptionFailure: () => {
|
||||
isMounted && dispatch({ type: ACTIONS.CHECK_SUBSCRIPTION_FAILURE, node });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
client,
|
||||
unsubscribe: () => {
|
||||
client?.stop();
|
||||
isMounted = false;
|
||||
},
|
||||
};
|
||||
};
|
@ -10,6 +10,7 @@ export const statisticsQuery = (
|
||||
__typename: 'Statistics',
|
||||
chainId: 'chain-id',
|
||||
blockHeight: '11',
|
||||
vegaTime: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -1,87 +0,0 @@
|
||||
import { StatisticsDocument, BlockTimeDocument } from './__generated__/Node';
|
||||
import type {
|
||||
StatisticsQuery,
|
||||
BlockTimeSubscription,
|
||||
} from './__generated__/Node';
|
||||
import { createClient } from '@vegaprotocol/apollo-client';
|
||||
|
||||
type Callbacks = {
|
||||
onStatsSuccess: (data: StatisticsQuery) => void;
|
||||
onStatsFailure: () => void;
|
||||
onSubscriptionSuccess: () => void;
|
||||
onSubscriptionFailure: () => void;
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_TIMEOUT = 3000;
|
||||
|
||||
/**
|
||||
* Makes a single stats request and attempts a subscrition to VegaTime
|
||||
* to determine whether or not a node is suitable for use
|
||||
*/
|
||||
export const requestNode = (
|
||||
url: string,
|
||||
{
|
||||
onStatsSuccess,
|
||||
onStatsFailure,
|
||||
onSubscriptionSuccess,
|
||||
onSubscriptionFailure,
|
||||
}: Callbacks
|
||||
) => {
|
||||
// check url is a valid url
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (err) {
|
||||
onStatsFailure();
|
||||
onSubscriptionFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
let subscriptionSucceeded = false;
|
||||
|
||||
const client = createClient({
|
||||
url,
|
||||
retry: false,
|
||||
connectToDevTools: false,
|
||||
});
|
||||
|
||||
// make a query for block height
|
||||
client
|
||||
.query<StatisticsQuery>({
|
||||
query: StatisticsDocument,
|
||||
})
|
||||
.then((res) => {
|
||||
onStatsSuccess(res.data);
|
||||
})
|
||||
.catch(() => {
|
||||
onStatsFailure();
|
||||
});
|
||||
|
||||
// start a subscription for VegaTime and await the first message
|
||||
const subscription = client
|
||||
.subscribe<BlockTimeSubscription>({
|
||||
query: BlockTimeDocument,
|
||||
errorPolicy: 'all',
|
||||
})
|
||||
.subscribe({
|
||||
next() {
|
||||
subscriptionSucceeded = true;
|
||||
onSubscriptionSuccess();
|
||||
subscription.unsubscribe();
|
||||
},
|
||||
error() {
|
||||
onSubscriptionFailure();
|
||||
subscription.unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
// start a timeout, if the above subscription doesn't yield any messages
|
||||
// before the timeout has completed consider it failed
|
||||
setTimeout(() => {
|
||||
if (!subscriptionSucceeded) {
|
||||
onSubscriptionFailure();
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
}, SUBSCRIPTION_TIMEOUT);
|
||||
|
||||
return client;
|
||||
};
|
@ -1,19 +1,5 @@
|
||||
import z from 'zod';
|
||||
import 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);
|
||||
}
|
||||
};
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -1,121 +0,0 @@
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
import { ErrorType } from '../types';
|
||||
import type { Networks, NodeData } from '../types';
|
||||
|
||||
export const getIsNodeLoading = (node?: NodeData): boolean => {
|
||||
if (!node) return false;
|
||||
return (
|
||||
node.chain.isLoading ||
|
||||
node.responseTime.isLoading ||
|
||||
node.block.isLoading ||
|
||||
node.subscription.isLoading
|
||||
);
|
||||
};
|
||||
|
||||
export const getIsInvalidUrl = (url: string) => {
|
||||
try {
|
||||
new URL(url);
|
||||
return false;
|
||||
} catch (err) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const getIsNodeDisabled = (env: Networks, data?: NodeData) => {
|
||||
return (
|
||||
!!data &&
|
||||
(getIsNodeLoading(data) ||
|
||||
getIsInvalidUrl(data.url) ||
|
||||
data.chain.hasError ||
|
||||
data.responseTime.hasError ||
|
||||
data.block.hasError ||
|
||||
data.subscription.hasError)
|
||||
);
|
||||
};
|
||||
|
||||
export const getIsFormDisabled = (
|
||||
currentNode: string | undefined,
|
||||
env: Networks,
|
||||
state: Record<string, NodeData>
|
||||
) => {
|
||||
if (!currentNode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const data = state[currentNode];
|
||||
return data ? getIsNodeDisabled(env, data) : true;
|
||||
};
|
||||
|
||||
export const getErrorByType = (
|
||||
errorType: ErrorType | undefined | null,
|
||||
env: Networks,
|
||||
url?: string
|
||||
) => {
|
||||
switch (errorType) {
|
||||
case ErrorType.INVALID_URL:
|
||||
return {
|
||||
headline: t('Error: invalid url'),
|
||||
message: t(url ? `${url} is not a valid url.` : ''),
|
||||
};
|
||||
case ErrorType.SUBSCRIPTION_ERROR:
|
||||
return {
|
||||
headline: t(`Error: the node you are reading from does not emit data`),
|
||||
message: t(
|
||||
url
|
||||
? `${url} is required to have subscriptions working to enable data updates on the page.`
|
||||
: ''
|
||||
),
|
||||
};
|
||||
case ErrorType.CONNECTION_ERROR:
|
||||
return {
|
||||
headline: t(`Error: can't connect to node`),
|
||||
message: t(url ? `There was an error connecting to ${url}.` : ''),
|
||||
};
|
||||
case ErrorType.CONNECTION_ERROR_ALL:
|
||||
return {
|
||||
headline: t(`Error: can't connect to any of the nodes on the network`),
|
||||
message: t(
|
||||
`Please try entering a custom node address, or try again later.`
|
||||
),
|
||||
};
|
||||
case ErrorType.CONFIG_VALIDATION_ERROR:
|
||||
return {
|
||||
headline: t(
|
||||
`Error: the configuration found for the network ${env} is invalid`
|
||||
),
|
||||
message: t(
|
||||
`Please try entering a custom node address, or try again later.`
|
||||
),
|
||||
};
|
||||
case ErrorType.CONFIG_LOAD_ERROR:
|
||||
return {
|
||||
headline: t(`Error: can't load network configuration`),
|
||||
message: t(
|
||||
`You can try entering a custom node address, or try again later.`
|
||||
),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getErrorType = (env: Networks, data?: NodeData) => {
|
||||
if (data && data.initialized) {
|
||||
if (getIsInvalidUrl(data.url)) {
|
||||
return ErrorType.INVALID_URL;
|
||||
}
|
||||
if (
|
||||
data.chain.hasError ||
|
||||
data.responseTime.hasError ||
|
||||
data.block.hasError
|
||||
) {
|
||||
return ErrorType.CONNECTION_ERROR;
|
||||
}
|
||||
|
||||
if (data.subscription.hasError) {
|
||||
return ErrorType.SUBSCRIPTION_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
import { 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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';
|
||||
|
9
libs/react-helpers/src/lib/is-valid-url.ts
Normal file
9
libs/react-helpers/src/lib/is-valid-url.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const isValidUrl = (url?: string) => {
|
||||
if (!url) return false;
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
@ -18,7 +18,7 @@ import {
|
||||
ViewConnector,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
|
@ -1,180 +0,0 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
act,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import type { RenderResult } from '@testing-library/react';
|
||||
import type { MockedResponse } from '@apollo/client/testing';
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
import { Web3Container } from './web3-container';
|
||||
import { Web3ConnectUncontrolledDialog } from './web3-connect-dialog';
|
||||
import type { useWeb3React } from '@web3-react/core';
|
||||
import type { NetworkParamsQuery } from '@vegaprotocol/react-helpers';
|
||||
import { NetworkParamsDocument } from '@vegaprotocol/react-helpers';
|
||||
import { EnvironmentProvider } from '@vegaprotocol/environment';
|
||||
|
||||
const defaultHookValue = {
|
||||
isActive: false,
|
||||
error: undefined,
|
||||
connector: null,
|
||||
chainId: 11155111,
|
||||
} as unknown as ReturnType<typeof useWeb3React>;
|
||||
let mockHookValue: ReturnType<typeof useWeb3React>;
|
||||
|
||||
const mockEthereumConfig = {
|
||||
network_id: '11155111',
|
||||
chain_id: '11155111',
|
||||
confirmations: 3,
|
||||
collateral_bridge_contract: {
|
||||
address: 'bridge address',
|
||||
},
|
||||
};
|
||||
|
||||
const networkParamsQueryMock: MockedResponse<NetworkParamsQuery> = {
|
||||
request: {
|
||||
query: NetworkParamsDocument,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
networkParametersConnection: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
__typename: 'NetworkParameter',
|
||||
key: 'blockchains.ethereumConfig',
|
||||
value: JSON.stringify(mockEthereumConfig),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockEnvironment = {
|
||||
VEGA_ENV: 'TESTNET',
|
||||
VEGA_URL: 'https://vega-node.url',
|
||||
VEGA_NETWORKS: JSON.stringify({}),
|
||||
GIT_BRANCH: 'test',
|
||||
GIT_COMMIT_HASH: 'abcdef',
|
||||
GIT_ORIGIN_URL: 'https://github.com/test/repo',
|
||||
};
|
||||
|
||||
jest.mock('@web3-react/core', () => {
|
||||
const original = jest.requireActual('@web3-react/core');
|
||||
return {
|
||||
...original,
|
||||
useWeb3React: jest.fn(() => mockHookValue),
|
||||
};
|
||||
});
|
||||
|
||||
let renderResults: RenderResult;
|
||||
async function setup(mock = networkParamsQueryMock) {
|
||||
await act(async () => {
|
||||
renderResults = await render(
|
||||
<EnvironmentProvider definitions={mockEnvironment}>
|
||||
<MockedProvider mocks={[mock]}>
|
||||
<Web3Container>
|
||||
<div>
|
||||
<div>Child</div>
|
||||
<div>{mockEthereumConfig.collateral_bridge_contract.address}</div>
|
||||
</div>
|
||||
</Web3Container>
|
||||
</MockedProvider>
|
||||
<Web3ConnectUncontrolledDialog />
|
||||
</EnvironmentProvider>
|
||||
);
|
||||
});
|
||||
return renderResults;
|
||||
}
|
||||
|
||||
describe('Web3Container', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('Prompt to connect opens dialog', async () => {
|
||||
mockHookValue = defaultHookValue;
|
||||
await setup();
|
||||
await waitFor(async () => {
|
||||
expect(
|
||||
await screen.findByText('Connect your Ethereum wallet')
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('Child')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('web3-connector-list')
|
||||
).not.toBeInTheDocument();
|
||||
await act(() => {
|
||||
fireEvent.click(screen.getByText('Connect'));
|
||||
});
|
||||
expect(screen.getByTestId('web3-connector-list')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Error message is shown', async () => {
|
||||
const message = 'Opps! An error';
|
||||
mockHookValue = { ...defaultHookValue, error: new Error(message) };
|
||||
await setup();
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(
|
||||
await screen.findByText(`Something went wrong: ${message}`)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Child')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Checks that chain ID matches app ID', async () => {
|
||||
const expectedChainId = 4;
|
||||
mockHookValue = {
|
||||
...defaultHookValue,
|
||||
isActive: true,
|
||||
chainId: expectedChainId,
|
||||
};
|
||||
await setup();
|
||||
expect(
|
||||
await screen.findByText(`This app only works on Sepolia`)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Child')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Passes ethereum config to children', async () => {
|
||||
mockHookValue = {
|
||||
...defaultHookValue,
|
||||
isActive: true,
|
||||
};
|
||||
await setup();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
mockEthereumConfig.collateral_bridge_contract.address
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Shows no config found message if the network parameter doesnt exist', async () => {
|
||||
const mock: MockedResponse<NetworkParamsQuery> = {
|
||||
request: {
|
||||
query: NetworkParamsDocument,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
networkParametersConnection: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
__typename: 'NetworkParameter',
|
||||
key: 'nope',
|
||||
value: 'foo',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await setup(mock);
|
||||
expect(await screen.findByText('No data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -1,147 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useWeb3React } from '@web3-react/core';
|
||||
import { AsyncRenderer, Button, Splash } from '@vegaprotocol/ui-toolkit';
|
||||
import { t } from '@vegaprotocol/react-helpers';
|
||||
import { useEnvironment } from '@vegaprotocol/environment';
|
||||
import { Web3Provider } from './web3-provider';
|
||||
import { useEthereumConfig } from './use-ethereum-config';
|
||||
import { useWeb3ConnectStore } from './web3-connect-store';
|
||||
import { createConnectors } from './web3-connectors';
|
||||
import { getChainName } from './constants';
|
||||
|
||||
interface Web3ContainerProps {
|
||||
children: ReactNode;
|
||||
childrenOnly?: boolean;
|
||||
connectEagerly?: boolean;
|
||||
}
|
||||
|
||||
export const Web3Container = ({
|
||||
children,
|
||||
childrenOnly,
|
||||
connectEagerly,
|
||||
}: Web3ContainerProps) => {
|
||||
const { config, loading, error } = useEthereumConfig();
|
||||
const { ETHEREUM_PROVIDER_URL, ETH_LOCAL_PROVIDER_URL, ETH_WALLET_MNEMONIC } =
|
||||
useEnvironment();
|
||||
const connectors = useMemo(() => {
|
||||
if (config?.chain_id) {
|
||||
return createConnectors(
|
||||
ETHEREUM_PROVIDER_URL,
|
||||
Number(config?.chain_id),
|
||||
ETH_LOCAL_PROVIDER_URL,
|
||||
ETH_WALLET_MNEMONIC
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
config?.chain_id,
|
||||
ETHEREUM_PROVIDER_URL,
|
||||
ETH_LOCAL_PROVIDER_URL,
|
||||
ETH_WALLET_MNEMONIC,
|
||||
]);
|
||||
return (
|
||||
<AsyncRenderer data={config} loading={loading} error={error}>
|
||||
{connectors && config && (
|
||||
<Web3Provider connectors={connectors}>
|
||||
<Web3Content
|
||||
connectEagerly={connectEagerly}
|
||||
childrenOnly={childrenOnly}
|
||||
appChainId={Number(config.chain_id)}
|
||||
connectors={connectors}
|
||||
>
|
||||
{children}
|
||||
</Web3Content>
|
||||
</Web3Provider>
|
||||
)}
|
||||
</AsyncRenderer>
|
||||
);
|
||||
};
|
||||
|
||||
interface Web3ContentProps {
|
||||
children: ReactNode;
|
||||
childrenOnly?: boolean;
|
||||
connectEagerly?: boolean;
|
||||
appChainId: number;
|
||||
connectors: ReturnType<typeof createConnectors>;
|
||||
}
|
||||
|
||||
export const Web3Content = ({
|
||||
children,
|
||||
childrenOnly,
|
||||
connectEagerly,
|
||||
appChainId,
|
||||
connectors,
|
||||
}: Web3ContentProps) => {
|
||||
const { isActive, error, connector, chainId } = useWeb3React();
|
||||
const openDialog = useWeb3ConnectStore((state) => state.open);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
connector?.connectEagerly &&
|
||||
(!('Cypress' in window) || connectEagerly)
|
||||
) {
|
||||
connector.connectEagerly();
|
||||
}
|
||||
// wallet connect doesnt handle connectEagerly being called when connector is also in the
|
||||
// deps array.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (childrenOnly) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SplashWrapper>
|
||||
<p className="mb-4">{t(`Something went wrong: ${error.message}`)}</p>
|
||||
<Button onClick={() => connector.deactivate()}>
|
||||
{t('Disconnect')}
|
||||
</Button>
|
||||
</SplashWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
return (
|
||||
<SplashWrapper>
|
||||
<p data-testid="connect-eth-wallet-msg" className="mb-4">
|
||||
{t('Connect your Ethereum wallet')}
|
||||
</p>
|
||||
<Button onClick={openDialog} data-testid="connect-eth-wallet-btn">
|
||||
{t('Connect')}
|
||||
</Button>
|
||||
</SplashWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (chainId !== appChainId) {
|
||||
return (
|
||||
<SplashWrapper>
|
||||
<p className="mb-4">
|
||||
{t(`This app only works on ${getChainName(appChainId)}`)}
|
||||
</p>
|
||||
<Button onClick={() => connector.deactivate()}>
|
||||
{t('Disconnect')}
|
||||
</Button>
|
||||
</SplashWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
interface SplashWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const SplashWrapper = ({ children }: SplashWrapperProps) => {
|
||||
return (
|
||||
<Splash>
|
||||
<div className="text-center">{children}</div>
|
||||
</Splash>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user