fix(environment): console not finding node (#3040)

This commit is contained in:
Matthew Russell 2023-02-28 23:45:57 -08:00 committed by GitHub
parent fcb992b64e
commit f128f41ea0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 232 additions and 46 deletions

View File

@ -11,7 +11,7 @@
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"lib": ["es5", "es6", "dom", "dom.iterable"] "lib": ["es2021", "dom", "dom.iterable"]
}, },
"exclude": ["./src/types/explorer.d.ts"], "exclude": ["./src/types/explorer.d.ts"],
"include": [], "include": [],

View File

@ -10,8 +10,7 @@
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true
"lib": ["es5", "es6", "dom", "dom.iterable"]
}, },
"include": [], "include": [],
"references": [ "references": [

View File

@ -1,14 +1,20 @@
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import type { ClientOptions } from '@vegaprotocol/apollo-client';
import { createClient } from '@vegaprotocol/apollo-client';
import { Networks } from '../types'; import { Networks } from '../types';
import { useEnvironment } from './use-environment'; import { STORAGE_KEY, useEnvironment } from './use-environment';
const noop = () => { const noop = () => {
/* no op*/ /* no op*/
}; };
jest.mock('@vegaprotocol/apollo-client', () => ({ jest.mock('@vegaprotocol/apollo-client');
createClient: () => ({ jest.mock('zustand');
const mockCreateClient = createClient as jest.Mock;
const createDefaultMockClient = () => {
return () => ({
query: () => query: () =>
Promise.resolve({ Promise.resolve({
data: { data: {
@ -25,12 +31,10 @@ jest.mock('@vegaprotocol/apollo-client', () => ({
obj.next(); obj.next();
}, },
}), }),
}), });
})); };
jest.mock('zustand');
global.fetch = jest.fn(); global.fetch = jest.fn();
// eslint-disable-next-line // eslint-disable-next-line
const setupFetch = (result: any) => { const setupFetch = (result: any) => {
return () => { return () => {
@ -56,6 +60,17 @@ const mockEnvVars = {
describe('useEnvironment', () => { describe('useEnvironment', () => {
const env = process.env; const env = process.env;
// eslint-disable-next-line
let warn: any;
beforeAll(() => {
warn = console.warn;
console.warn = noop;
});
afterAll(() => {
console.warn = warn;
});
beforeEach(() => { beforeEach(() => {
jest.resetModules(); jest.resetModules();
@ -73,6 +88,9 @@ describe('useEnvironment', () => {
// @ts-ignore clear mocked node config fetch // @ts-ignore clear mocked node config fetch
fetch.mockClear(); fetch.mockClear();
// reset default apollo client behaviour
mockCreateClient.mockImplementation(createDefaultMockClient());
}); });
afterEach(() => { afterEach(() => {
@ -92,6 +110,7 @@ describe('useEnvironment', () => {
]; ];
// @ts-ignore: typscript doesn't recognise the mock implementation // @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(setupFetch({ hosts: nodes })); global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
const { result } = setup(); const { result } = setup();
expect(result.current.status).toBe('default'); expect(result.current.status).toBe('default');
@ -107,10 +126,169 @@ describe('useEnvironment', () => {
}); });
// resulting VEGA_URL should be one of the nodes from the config // resulting VEGA_URL should be one of the nodes from the config
expect( expect(nodes.includes(result.current.VEGA_URL as string)).toBe(true);
result.current.VEGA_URL === nodes[0] || expect(result.current).toMatchObject({
result.current.VEGA_URL === nodes[1] ...mockEnvVars,
).toBe(true); nodes,
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(configUrl);
});
it('uses the first successfully responding node', async () => {
jest.useFakeTimers();
const configUrl = 'https://vega.xyz/testnet-config.json';
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
const slowNode = 'https://api.n00.foo.vega.xyz';
const slowWait = 2000;
const fastNode = 'https://api.n01.foo.vega.xyz';
const fastWait = 1000;
const nodes = [slowNode, fastNode];
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
mockCreateClient.mockImplementation((obj: ClientOptions) => ({
query: () =>
new Promise((resolve) => {
const wait = obj.url === fastNode ? fastWait : slowWait;
setTimeout(() => {
resolve({
data: {
statistics: {
chainId: 'chain-id',
blockHeight: '100',
vegaTime: new Date().toISOString(),
},
},
});
}, wait);
}),
subscribe: () => ({
// eslint-disable-next-line
subscribe: (obj: any) => {
obj.next();
},
}),
}));
const { result } = setup();
expect(result.current.status).toBe('default');
act(() => {
result.current.initialize();
});
expect(result.current.status).toBe('pending');
// wait for nodes request to finish before running timer
await waitFor(() => {
expect(result.current.nodes).toEqual(nodes);
});
jest.runAllTimers();
await waitFor(() => {
expect(result.current.status).toEqual('success');
});
expect(result.current.VEGA_URL).toBe(fastNode);
expect(localStorage.getItem(STORAGE_KEY)).toBe(fastNode);
expect(result.current).toMatchObject({
...mockEnvVars,
nodes,
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(configUrl);
jest.useRealTimers();
});
it('passes a node if both queries and subscriptions working', async () => {
const configUrl = 'https://vega.xyz/testnet-config.json';
process.env['NX_VEGA_CONFIG_URL'] = configUrl;
const successNode = 'https://api.n01.foo.vega.xyz';
const failNode = 'https://api.n00.foo.vega.xyz';
const nodes = [failNode, successNode];
// @ts-ignore: typscript doesn't recognise the mock implementation
global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
mockCreateClient.mockImplementation((clientOptions: ClientOptions) => ({
query: () =>
Promise.resolve({
data: {
statistics: {
chainId: 'chain-id',
blockHeight: '100',
vegaTime: new Date().toISOString(),
},
},
}),
subscribe: () => ({
// eslint-disable-next-line
subscribe: (obj: any) => {
if (clientOptions.url === failNode) {
// make n00 fail the subscription
obj.error();
} else {
obj.next();
}
},
}),
}));
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');
});
expect(result.current.VEGA_URL).toBe(successNode);
expect(localStorage.getItem(STORAGE_KEY)).toBe(successNode);
});
it('fails initialization if no suitable node is found', 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 }));
// set all clients to fail both query and subscription
mockCreateClient.mockImplementation(() => ({
query: () => Promise.reject(),
subscribe: () => ({
// eslint-disable-next-line
subscribe: (obj: any) => {
obj.error();
},
}),
}));
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('failed');
});
expect(result.current.VEGA_URL).toBe(undefined); // VEGA_URL is unset, app should handle some UI
expect(result.current).toMatchObject({ expect(result.current).toMatchObject({
...mockEnvVars, ...mockEnvVars,
nodes, nodes,
@ -154,9 +332,12 @@ describe('useEnvironment', () => {
process.env['NX_VEGA_URL'] = url; process.env['NX_VEGA_URL'] = url;
process.env['NX_VEGA_CONFIG_URL'] = undefined; process.env['NX_VEGA_CONFIG_URL'] = undefined;
const { result } = setup(); const { result } = setup();
await act(async () => { act(() => {
result.current.initialize(); result.current.initialize();
}); });
await waitFor(() => {
expect(result.current.status).toBe('success');
});
expect(result.current).toMatchObject({ expect(result.current).toMatchObject({
VEGA_URL: url, VEGA_URL: url,
VEGA_CONFIG_URL: undefined, VEGA_CONFIG_URL: undefined,
@ -174,16 +355,16 @@ describe('useEnvironment', () => {
// @ts-ignore setup mock fetch for config url // @ts-ignore setup mock fetch for config url
global.fetch.mockImplementation(setupFetch({ hosts: nodes })); global.fetch.mockImplementation(setupFetch({ hosts: nodes }));
const { result } = setup(); const { result } = setup();
await act(async () => { act(() => {
result.current.initialize(); result.current.initialize();
}); });
expect(typeof result.current.VEGA_URL).toEqual('string'); await waitFor(() => {
expect(result.current.VEGA_URL).not.toBeFalsy(); expect(result.current.status).toBe('success');
});
expect(nodes.includes(result.current.VEGA_URL as string)).toBe(true);
}); });
it('handles error if node config cannot be fetched', async () => { 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'; const configUrl = 'https://vega.xyz/testnet-config.json';
process.env['NX_VEGA_CONFIG_URL'] = configUrl; process.env['NX_VEGA_CONFIG_URL'] = configUrl;
process.env['NX_VEGA_URL'] = undefined; process.env['NX_VEGA_URL'] = undefined;
@ -196,16 +377,14 @@ describe('useEnvironment', () => {
result.current.initialize(); result.current.initialize();
}); });
expect(result.current.status).toEqual('failed'); expect(result.current.status).toEqual('failed');
expect(typeof result.current.error).toBe('string'); expect(result.current.error).toBe(
expect(result.current.error).toBeTruthy(); `Failed to fetch node config from ${configUrl}`
);
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(configUrl); expect(fetch).toHaveBeenCalledWith(configUrl);
console.warn = warn;
}); });
it('handles an invalid node config', async () => { it('handles an invalid node config', async () => {
const warn = console.warn;
console.warn = noop;
const configUrl = 'https://vega.xyz/testnet-config.json'; const configUrl = 'https://vega.xyz/testnet-config.json';
process.env['NX_VEGA_CONFIG_URL'] = configUrl; process.env['NX_VEGA_CONFIG_URL'] = configUrl;
process.env['NX_VEGA_URL'] = undefined; process.env['NX_VEGA_URL'] = undefined;
@ -216,11 +395,11 @@ describe('useEnvironment', () => {
result.current.initialize(); result.current.initialize();
}); });
expect(result.current.status).toEqual('failed'); expect(result.current.status).toEqual('failed');
expect(typeof result.current.error).toBe('string'); expect(result.current.error).toBe(
expect(result.current.error).toBeTruthy(); `Failed to fetch node config from ${configUrl}`
);
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(configUrl); expect(fetch).toHaveBeenCalledWith(configUrl);
console.warn = warn;
}); });
it('uses stored url', async () => { it('uses stored url', async () => {
@ -231,7 +410,7 @@ describe('useEnvironment', () => {
setupFetch({ hosts: ['http://foo.bar.com'] }) setupFetch({ hosts: ['http://foo.bar.com'] })
); );
const url = 'https://api.n00.foo.com'; const url = 'https://api.n00.foo.com';
localStorage.setItem('vega_url', url); localStorage.setItem(STORAGE_KEY, url);
const { result } = setup(); const { result } = setup();
await act(async () => { await act(async () => {
result.current.initialize(); result.current.initialize();
@ -254,6 +433,6 @@ describe('useEnvironment', () => {
result.current.setUrl(newUrl); result.current.setUrl(newUrl);
}); });
expect(result.current.VEGA_URL).toBe(newUrl); expect(result.current.VEGA_URL).toBe(newUrl);
expect(localStorage.getItem('vega_url')).toBe(newUrl); expect(localStorage.getItem(STORAGE_KEY)).toBe(newUrl);
}); });
}); });

View File

@ -31,7 +31,7 @@ type Actions = {
export type Env = Environment & EnvState; export type Env = Environment & EnvState;
export type EnvStore = Env & Actions; export type EnvStore = Env & Actions;
const STORAGE_KEY = 'vega_url'; export const STORAGE_KEY = 'vega_url';
const SUBSCRIPTION_TIMEOUT = 3000; const SUBSCRIPTION_TIMEOUT = 3000;
export const useEnvironment = create<EnvStore>((set, get) => ({ export const useEnvironment = create<EnvStore>((set, get) => ({
@ -168,9 +168,15 @@ const fetchConfig = async (url?: string) => {
* Find a suitable node by running a test query and test * Find a suitable node by running a test query and test
* subscription, against a list of clients, first to resolve wins * subscription, against a list of clients, first to resolve wins
*/ */
const findNode = (clients: ClientCollection): Promise<string | null> => { const findNode = async (clients: ClientCollection): Promise<string | null> => {
const tests = Object.entries(clients).map((args) => testNode(...args)); const tests = Object.entries(clients).map((args) => testNode(...args));
return Promise.race(tests); try {
const url = await Promise.any(tests);
return url;
} catch {
// All tests rejected, no suitable node found
return null;
}
}; };
/** /**
@ -180,19 +186,21 @@ const testNode = async (
url: string, url: string,
client: Client client: Client
): Promise<string | null> => { ): Promise<string | null> => {
try { const results = await Promise.all([
const results = await Promise.all([ // these promises will only resolve with true/false
testQuery(client), testQuery(client),
testSubscription(client), testSubscription(client),
]); ]);
if (results[0] && results[1]) { if (results[0] && results[1]) {
return url; return url;
}
return null;
} catch (err) {
console.warn(`Tests failed for ${url}`);
return null;
} }
const message = `Tests failed for node: ${url}`;
console.warn(message);
// throwing here will mean this tests is ignored and a different
// node that hopefully does resolve will fulfill the Promise.any
throw new Error(message);
}; };
/** /**

View File

@ -10,7 +10,7 @@
"importHelpers": true, "importHelpers": true,
"target": "es2015", "target": "es2015",
"module": "esnext", "module": "esnext",
"lib": ["es2017", "dom"], "lib": ["es2021", "dom"],
"skipLibCheck": true, "skipLibCheck": true,
"skipDefaultLibCheck": true, "skipDefaultLibCheck": true,
"baseUrl": ".", "baseUrl": ".",