fix(environment): missing block height in node switcher (#5210)

This commit is contained in:
Art 2023-11-08 09:47:23 +01:00 committed by GitHub
parent 7aee2a3a7b
commit 607ad06971
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 228 additions and 149 deletions

View File

@ -1,6 +1,12 @@
import type { MockedResponse } from '@apollo/react-testing';
import { MockedProvider } from '@apollo/react-testing';
import { act, render, screen, waitFor } from '@testing-library/react';
import {
act,
render,
renderHook,
screen,
waitFor,
} from '@testing-library/react';
import { RadioGroup } from '@vegaprotocol/ui-toolkit';
import type {
NodeCheckTimeUpdateSubscription,
@ -11,30 +17,33 @@ import {
NodeCheckTimeUpdateDocument,
} from '../../utils/__generated__/NodeCheck';
import type { RowDataProps } from './row-data';
import { POLL_INTERVAL } from './row-data';
import {
POLL_INTERVAL,
Result,
SUBSCRIPTION_TIMEOUT,
useNodeBasicStatus,
useNodeSubscriptionStatus,
useResponseTime,
} 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<NodeCheckQuery> = {
const mockStatsQuery = (
blockHeight = '1234'
): MockedResponse<NodeCheckQuery> => ({
request: {
query: NodeCheckDocument,
},
result: {
data: {
statistics: {
blockHeight: '1234', // the actual value used in the component is the value from the header store
blockHeight,
vegaTime: new Date().toISOString(),
chainId: 'test-chain-id',
},
},
},
};
});
const subMock: MockedResponse<NodeCheckTimeUpdateSubscription> = {
request: {
@ -59,18 +68,6 @@ global.performance.getEntriesByName = jest.fn().mockReturnValue([
},
]);
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<NodeCheckQuery>,
@ -86,6 +83,98 @@ const renderComponent = (
);
};
describe('useNodeSubscriptionStatus', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
const mockWrapper =
(withData = false) =>
({ children }: { children: React.ReactNode }) =>
(
<MockedProvider mocks={withData ? [subMock, subMock, subMock] : []}>
{children}
</MockedProvider>
);
it('results initially as loading', async () => {
const { result } = renderHook(() => useNodeSubscriptionStatus(), {
wrapper: mockWrapper(true),
});
expect(result.current.status).toBe(Result.Loading);
});
it('results as successful when data received', async () => {
const { result } = renderHook(() => useNodeSubscriptionStatus(), {
wrapper: mockWrapper(true),
});
expect(result.current.status).toBe(Result.Loading);
await act(() => {
jest.advanceTimersByTime(SUBSCRIPTION_TIMEOUT);
});
expect(result.current.status).toBe(Result.Successful);
});
it('result as failed when no data received', async () => {
const { result } = renderHook(() => useNodeSubscriptionStatus(), {
wrapper: mockWrapper(false),
});
expect(result.current.status).toBe(Result.Loading);
await act(() => {
jest.advanceTimersByTime(SUBSCRIPTION_TIMEOUT);
});
expect(result.current.status).toBe(Result.Failed);
});
});
describe('useNodeBasicStatus', () => {
const mockWrapper =
(withData = false) =>
({ children }: { children: React.ReactNode }) =>
(
<MockedProvider mocks={withData ? [mockStatsQuery('1234')] : []}>
{children}
</MockedProvider>
);
it('results initially as loading', async () => {
const { result } = renderHook(() => useNodeBasicStatus(), {
wrapper: mockWrapper(true),
});
expect(result.current.status).toBe(Result.Loading);
expect(result.current.currentBlockHeight).toBeNaN();
});
it('results as successful when data received', async () => {
const { result } = renderHook(() => useNodeBasicStatus(), {
wrapper: mockWrapper(true),
});
await waitFor(() => {
expect(result.current.status).toBe(Result.Successful);
expect(result.current.currentBlockHeight).toBe(1234);
});
});
it('result as failed when no data received', async () => {
const { result } = renderHook(() => useNodeBasicStatus(), {
wrapper: mockWrapper(false),
});
await waitFor(() => {
expect(result.current.status).toBe(Result.Failed);
expect(result.current.currentBlockHeight).toBeNaN();
});
});
});
describe('useResponseTime', () => {
it('returns response time when url is valid', () => {
const { result } = renderHook(() =>
useResponseTime('https://localhost:1234')
);
expect(result.current.responseTime).toBe(50);
});
it('does not return response time when url is invalid', () => {
const { result } = renderHook(() => useResponseTime('nope'));
expect(result.current.responseTime).toBeUndefined();
});
});
describe('RowData', () => {
const props = {
id: '0',
@ -94,9 +183,13 @@ describe('RowData', () => {
onBlockHeight: jest.fn(),
};
afterAll(() => {
jest.useRealTimers();
jest.resetAllMocks();
});
it('radio button enabled after stats query successful', async () => {
mockHeaders(props.url);
render(renderComponent(props, statsQueryMock, subMock));
render(renderComponent(props, mockStatsQuery('100'), subMock));
// radio should be enabled until query resolves
expect(
@ -127,8 +220,6 @@ describe('RowData', () => {
});
it('radio button still enabled if query fails', async () => {
mockHeaders(props.url, {});
const failedQueryMock: MockedResponse<NodeCheckQuery> = {
request: {
query: NodeCheckDocument,
@ -178,12 +269,11 @@ describe('RowData', () => {
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,
mockStatsQuery(String(blockHeight)),
subMock
)
);
@ -201,7 +291,7 @@ describe('RowData', () => {
rerender(
renderComponent(
{ ...props, highestBlock: blockHeight + BLOCK_THRESHOLD + 1 },
statsQueryMock,
mockStatsQuery(String(blockHeight)),
subMock
)
);
@ -216,7 +306,7 @@ describe('RowData', () => {
...props,
id: CUSTOM_NODE_KEY,
},
statsQueryMock,
mockStatsQuery('1234'),
subMock
)
);
@ -230,16 +320,17 @@ describe('RowData', () => {
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,
mockStatsQuery(String(blockHeight)),
subMock
)
);
expect(mockOnBlockHeight).toHaveBeenCalledWith(blockHeight);
await waitFor(() => {
expect(mockOnBlockHeight).toHaveBeenCalledWith(blockHeight);
});
});
it('should poll the query unless an errors is returned', async () => {
@ -275,7 +366,6 @@ describe('RowData', () => {
};
};
mockHeaders(props.url);
const statsQueryMock1 = createStatsQueryMock('1234');
const statsQueryMock2 = createStatsQueryMock('1235');
const statsQueryMock3 = createFailedStatsQueryMock();

View File

@ -1,5 +1,3 @@
import type { ApolloError } from '@apollo/client';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
import { isValidUrl } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import { TradingRadio } from '@vegaprotocol/ui-toolkit';
@ -12,6 +10,7 @@ import {
import { LayoutCell } from './layout-cell';
export const POLL_INTERVAL = 1000;
export const SUBSCRIPTION_TIMEOUT = 3000;
export const BLOCK_THRESHOLD = 3;
export interface RowDataProps {
@ -21,15 +20,39 @@ export interface RowDataProps {
onBlockHeight: (blockHeight: number) => void;
}
export const RowData = ({
id,
url,
highestBlock,
onBlockHeight,
}: RowDataProps) => {
const [subFailed, setSubFailed] = useState(false);
const [time, setTime] = useState<number>();
// no use of data here as we need the data nodes reference to block height
export enum Result {
Successful,
Failed,
Loading,
}
export const useNodeSubscriptionStatus = () => {
const [status, setStatus] = useState<Result>(Result.Loading);
const { data, error } = useNodeCheckTimeUpdateSubscription();
useEffect(() => {
if (error) {
setStatus(Result.Failed);
}
if (data?.busEvents && data.busEvents.length > 0) {
setStatus(Result.Successful);
}
// set as failed when no data received after SUBSCRIPTION_TIMEOUT ms
const timeout = setTimeout(() => {
if (!data || error) {
setStatus(Result.Failed);
}
}, SUBSCRIPTION_TIMEOUT);
return () => {
clearTimeout(timeout);
};
}, [data, error]);
return { status };
};
export const useNodeBasicStatus = () => {
const [status, setStatus] = useState<Result>(Result.Loading);
const { data, error, loading, startPolling, stopPolling } = useNodeCheckQuery(
{
pollInterval: POLL_INTERVAL,
@ -38,28 +61,7 @@ export const RowData = ({
ssr: false,
}
);
const headerStore = useHeaderStore();
const headers = headerStore[url];
const {
data: subData,
error: subError,
loading: subLoading,
} = useNodeCheckTimeUpdateSubscription();
useEffect(() => {
const timeout = setTimeout(() => {
if (!subData) {
setSubFailed(true);
}
}, 3000);
return () => {
clearTimeout(timeout);
};
}, [subData]);
// handle polling
useEffect(() => {
const handleStartPoll = () => {
if (error) return;
@ -83,56 +85,57 @@ export const RowData = ({
};
}, [startPolling, stopPolling, error]);
// measure response time
const currentBlockHeight = parseInt(
data?.statistics.blockHeight || 'NONE',
10
);
useEffect(() => {
if (loading) {
setStatus(Result.Loading);
return;
}
if (!error && !isNaN(currentBlockHeight)) {
setStatus(Result.Successful);
return;
}
setStatus(Result.Failed);
}, [currentBlockHeight, error, loading]);
return {
status,
currentBlockHeight,
};
};
export const useResponseTime = (url: string, trigger?: unknown) => {
const [responseTime, setResponseTime] = useState<number>();
useEffect(() => {
if (!isValidUrl(url)) return;
if (typeof window.performance.getEntriesByName !== 'function') return; // protection for test environment
// 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]);
setResponseTime(duration);
}, [url, trigger]);
return { responseTime };
};
export const RowData = ({
id,
url,
highestBlock,
onBlockHeight,
}: RowDataProps) => {
const { status: subStatus } = useNodeSubscriptionStatus();
const { status, currentBlockHeight } = useNodeBasicStatus();
const { responseTime } = useResponseTime(url, currentBlockHeight); // measure response time (ms) every time we get data (block height)
useEffect(() => {
if (headers?.blockHeight) {
onBlockHeight(headers.blockHeight);
if (!isNaN(currentBlockHeight)) {
onBlockHeight(currentBlockHeight);
}
}, [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 getSubFailed = (
subError: ApolloError | undefined,
subFailed: boolean
) => {
if (subError) return true;
if (subFailed) return true;
return false;
};
}, [currentBlockHeight, onBlockHeight]);
return (
<>
@ -143,72 +146,58 @@ export const RowData = ({
)}
<LayoutCell
label={t('Response time')}
isLoading={!error && loading}
hasError={Boolean(error)}
isLoading={status === Result.Loading}
hasError={status === Result.Failed}
dataTestId="response-time-cell"
>
{getResponseTimeDisplayValue(time, error)}
{display(status, formatResponseTime(responseTime))}
</LayoutCell>
<LayoutCell
label={t('Block')}
isLoading={loading}
hasError={getHasError()}
isLoading={status === Result.Loading}
hasError={
status === Result.Failed ||
(highestBlock != null &&
!isNaN(currentBlockHeight) &&
currentBlockHeight < highestBlock - BLOCK_THRESHOLD)
}
dataTestId="block-height-cell"
>
<span
data-testid="query-block-height"
data-query-block-height={
error ? 'failed' : data?.statistics.blockHeight
status === Result.Failed ? 'failed' : currentBlockHeight
}
>
{getBlockDisplayValue(headers?.blockHeight, error)}
{display(status, currentBlockHeight)}
</span>
</LayoutCell>
<LayoutCell
label={t('Subscription')}
isLoading={subFailed ? false : subLoading}
hasError={getSubFailed(subError, subFailed)}
isLoading={subStatus === Result.Loading}
hasError={subStatus === Result.Failed}
dataTestId="subscription-cell"
>
{getSubscriptionDisplayValue(subFailed, subData?.busEvents, subError)}
{display(subStatus, t('Yes'), t('No'))}
</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 formatResponseTime = (time: number | undefined) =>
time != null ? `${Number(time).toFixed(2)}ms` : '-';
const getBlockDisplayValue = (block?: number, error?: ApolloError) => {
if (error) {
return t('n/a');
}
if (block) {
return block;
}
return '-';
};
const getSubscriptionDisplayValue = (
subFailed: boolean,
events?: { id: string }[] | null,
error?: ApolloError
const display = (
status: Result,
yes: string | number | undefined,
no = t('n/a')
) => {
if (subFailed || error) {
return t('No');
switch (status) {
case Result.Successful:
return yes;
case Result.Failed:
return no;
default:
return '-';
}
if (events?.length) {
return t('Yes');
}
return '-';
};