1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-10-22 11:22:43 +02:00

Reduce calls of the /featureFlag API (#10859)

* Remove periodic calls to the /featureFlag API

* Always show the Swap button on the main page

* Check if the Swaps feature is enabled, show loading animation while waiting

* Reuse an existing useEffect call

* Use ‘isFeatureFlagLoaded’ in React’s state, resolve lint issues

* Add a watch mode for Jest testing

* Add unit tests for Swaps: fetchSwapsLiveness, add /ducks/swaps into Jest testing

* Remove Swaps Jest tests from Mocha’s ESLint rules

* Ignore Swaps Jest tests while running Mocha, update paths

* Increase test coverage to the current max

* Fix ESLint issues for Swaps

* Enable the Swaps feature by default and after state reset, remove loading screen before seeing Swaps

* Update Jest config, fix tests

* Update Jest coverage threshold to the current maximum

* Update ESLint rule in jest.config.js

* Disable the “Review Swap” button if the feature flag hasn’t loaded yet

* Update jest threshold
This commit is contained in:
Daniel 2021-04-14 00:16:27 -07:00 committed by GitHub
parent 9e918b6026
commit e7d7d24d83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 217 additions and 455 deletions

View File

@ -108,14 +108,14 @@ module.exports = {
}, },
{ {
files: ['**/*.test.js'], files: ['**/*.test.js'],
excludedFiles: ['ui/app/pages/swaps/**/*.test.js'], excludedFiles: ['ui/app/**/swaps/**/*.test.js'],
extends: ['@metamask/eslint-config-mocha'], extends: ['@metamask/eslint-config-mocha'],
rules: { rules: {
'mocha/no-setup-in-describe': 'off', 'mocha/no-setup-in-describe': 'off',
}, },
}, },
{ {
files: ['ui/app/pages/swaps/**/*.test.js'], files: ['ui/app/**/swaps/**/*.test.js'],
extends: ['@metamask/eslint-config-jest'], extends: ['@metamask/eslint-config-jest'],
}, },
{ {

View File

@ -70,7 +70,7 @@ const initialState = {
errorKey: '', errorKey: '',
topAggId: null, topAggId: null,
routeState: '', routeState: '',
swapsFeatureIsLive: false, swapsFeatureIsLive: true,
swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME, swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME,
}, },
}; };
@ -112,8 +112,6 @@ export default class SwapsController {
this.ethersProvider = new ethers.providers.Web3Provider(provider); this.ethersProvider = new ethers.providers.Web3Provider(provider);
} }
}); });
this._setupSwapsLivenessFetching();
} }
// Sets the refresh rate for quote updates from the MetaSwap API // Sets the refresh rate for quote updates from the MetaSwap API
@ -478,7 +476,6 @@ export default class SwapsController {
swapsState: { swapsState: {
...initialState.swapsState, ...initialState.swapsState,
tokens: swapsState.tokens, tokens: swapsState.tokens,
swapsFeatureIsLive: swapsState.swapsFeatureIsLive,
swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime, swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime,
}, },
}); });
@ -686,99 +683,6 @@ export default class SwapsController {
SWAPS_CHAINID_CONTRACT_ADDRESS_MAP[chainId], SWAPS_CHAINID_CONTRACT_ADDRESS_MAP[chainId],
); );
} }
/**
* Sets up the fetching of the swaps feature liveness flag from our API.
* Performs an initial fetch when called, then fetches on a 10-minute
* interval.
*
* If the browser goes offline, the interval is cleared and swaps are disabled
* until the value can be fetched again.
*/
_setupSwapsLivenessFetching() {
const TEN_MINUTES_MS = 10 * 60 * 1000;
let intervalId = null;
const fetchAndSetupInterval = () => {
if (window.navigator.onLine && intervalId === null) {
// Set the interval first to prevent race condition between listener and
// initial call to this function.
intervalId = setInterval(
this._fetchAndSetSwapsLiveness.bind(this),
TEN_MINUTES_MS,
);
this._fetchAndSetSwapsLiveness();
}
};
window.addEventListener('online', fetchAndSetupInterval);
window.addEventListener('offline', () => {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
const { swapsState } = this.store.getState();
if (swapsState.swapsFeatureIsLive) {
this.setSwapsLiveness(false);
}
}
});
fetchAndSetupInterval();
}
/**
* This function should only be called via _setupSwapsLivenessFetching.
*
* Attempts to fetch the swaps feature liveness flag from our API. Tries
* to fetch three times at 5-second intervals before giving up, in which
* case the value defaults to 'false'.
*
* Only updates state if the fetched/computed flag value differs from current
* state.
*/
async _fetchAndSetSwapsLiveness() {
const { swapsState } = this.store.getState();
const { swapsFeatureIsLive: oldSwapsFeatureIsLive } = swapsState;
const chainId = this._getCurrentChainId();
let swapsFeatureIsLive = false;
let successfullyFetched = false;
let numAttempts = 0;
const fetchAndIncrementNumAttempts = async () => {
try {
swapsFeatureIsLive = Boolean(
await this._fetchSwapsFeatureLiveness(chainId),
);
successfullyFetched = true;
} catch (err) {
log.error(err);
numAttempts += 1;
}
};
await fetchAndIncrementNumAttempts();
// The loop conditions are modified by fetchAndIncrementNumAttempts.
// eslint-disable-next-line no-unmodified-loop-condition
while (!successfullyFetched && numAttempts < 3) {
await new Promise((resolve) => {
setTimeout(resolve, 5000); // 5 seconds
});
await fetchAndIncrementNumAttempts();
}
if (!successfullyFetched) {
log.error(
'Failed to fetch swaps feature flag 3 times. Setting to false and trying again next interval.',
);
}
if (swapsFeatureIsLive !== oldSwapsFeatureIsLive) {
this.setSwapsLiveness(swapsFeatureIsLive);
}
}
} }
/** /**

View File

@ -124,7 +124,7 @@ const EMPTY_INIT_STATE = {
errorKey: '', errorKey: '',
topAggId: null, topAggId: null,
routeState: '', routeState: '',
swapsFeatureIsLive: false, swapsFeatureIsLive: true,
swapsQuoteRefreshTime: 60000, swapsQuoteRefreshTime: 60000,
}, },
}; };
@ -897,281 +897,6 @@ describe('SwapsController', function () {
}); });
}); });
}); });
describe('_setupSwapsLivenessFetching ', function () {
let clock;
const EXPECTED_TIME = 600000;
const getLivenessState = () => {
return swapsController.store.getState().swapsState.swapsFeatureIsLive;
};
// We have to do this to overwrite window.navigator.onLine
const stubWindow = () => {
sandbox.replace(global, 'window', {
addEventListener: window.addEventListener,
navigator: { onLine: true },
dispatchEvent: window.dispatchEvent,
Event: window.Event,
});
};
beforeEach(function () {
stubWindow();
clock = sandbox.useFakeTimers();
sandbox.spy(clock, 'setInterval');
sandbox
.stub(SwapsController.prototype, '_fetchAndSetSwapsLiveness')
.resolves(undefined);
sandbox.spy(SwapsController.prototype, '_setupSwapsLivenessFetching');
sandbox.spy(window, 'addEventListener');
});
afterEach(function () {
sandbox.restore();
});
it('calls _setupSwapsLivenessFetching in constructor', function () {
swapsController = getSwapsController();
assert.ok(
swapsController._setupSwapsLivenessFetching.calledOnce,
'should have called _setupSwapsLivenessFetching once',
);
assert.ok(window.addEventListener.calledWith('online'));
assert.ok(window.addEventListener.calledWith('offline'));
assert.ok(
clock.setInterval.calledOnceWithExactly(
sinon.match.func,
EXPECTED_TIME,
),
'should have set an interval',
);
});
it('handles browser being offline on boot, then coming online', async function () {
window.navigator.onLine = false;
swapsController = getSwapsController();
assert.ok(
swapsController._setupSwapsLivenessFetching.calledOnce,
'should have called _setupSwapsLivenessFetching once',
);
assert.ok(
swapsController._fetchAndSetSwapsLiveness.notCalled,
'should not have called _fetchAndSetSwapsLiveness',
);
assert.ok(
clock.setInterval.notCalled,
'should not have set an interval',
);
assert.strictEqual(
getLivenessState(),
false,
'swaps feature should be disabled',
);
const fetchPromise = new Promise((resolve) => {
const originalFunction = swapsController._fetchAndSetSwapsLiveness;
swapsController._fetchAndSetSwapsLiveness = () => {
originalFunction();
resolve();
swapsController._fetchAndSetSwapsLiveness = originalFunction;
};
});
// browser comes online
window.navigator.onLine = true;
window.dispatchEvent(new window.Event('online'));
await fetchPromise;
assert.ok(
swapsController._fetchAndSetSwapsLiveness.calledOnce,
'should have called _fetchAndSetSwapsLiveness once',
);
assert.ok(
clock.setInterval.calledOnceWithExactly(
sinon.match.func,
EXPECTED_TIME,
),
'should have set an interval',
);
});
it('clears interval if browser goes offline', async function () {
swapsController = getSwapsController();
// set feature to live
const { swapsState } = swapsController.store.getState();
swapsController.store.updateState({
swapsState: { ...swapsState, swapsFeatureIsLive: true },
});
sandbox.spy(swapsController.store, 'updateState');
assert.ok(
clock.setInterval.calledOnceWithExactly(
sinon.match.func,
EXPECTED_TIME,
),
'should have set an interval',
);
const clearIntervalPromise = new Promise((resolve) => {
const originalFunction = clock.clearInterval;
clock.clearInterval = (intervalId) => {
originalFunction(intervalId);
clock.clearInterval = originalFunction;
resolve();
};
});
// browser goes offline
window.navigator.onLine = false;
window.dispatchEvent(new window.Event('offline'));
// if this resolves, clearInterval was called
await clearIntervalPromise;
assert.ok(
swapsController._fetchAndSetSwapsLiveness.calledOnce,
'should have called _fetchAndSetSwapsLiveness once',
);
assert.ok(
swapsController.store.updateState.calledOnce,
'should have called updateState once',
);
assert.strictEqual(
getLivenessState(),
false,
'swaps feature should be disabled',
);
});
});
describe('_fetchAndSetSwapsLiveness', function () {
const getLivenessState = () => {
return swapsController.store.getState().swapsState.swapsFeatureIsLive;
};
beforeEach(function () {
fetchSwapsFeatureLivenessStub.reset();
sandbox.stub(SwapsController.prototype, '_setupSwapsLivenessFetching');
swapsController = getSwapsController();
});
afterEach(function () {
sandbox.restore();
});
it('fetches feature liveness as expected when API is live', async function () {
fetchSwapsFeatureLivenessStub.resolves(true);
assert.strictEqual(
getLivenessState(),
false,
'liveness should be false on boot',
);
await swapsController._fetchAndSetSwapsLiveness();
assert.ok(
fetchSwapsFeatureLivenessStub.calledOnce,
'should have called fetch function once',
);
assert.strictEqual(
getLivenessState(),
true,
'liveness should be true after call',
);
});
it('does not update state if fetched value is same as state value', async function () {
fetchSwapsFeatureLivenessStub.resolves(false);
sandbox.spy(swapsController.store, 'updateState');
assert.strictEqual(
getLivenessState(),
false,
'liveness should be false on boot',
);
await swapsController._fetchAndSetSwapsLiveness();
assert.ok(
fetchSwapsFeatureLivenessStub.calledOnce,
'should have called fetch function once',
);
assert.ok(
swapsController.store.updateState.notCalled,
'should not have called store.updateState',
);
assert.strictEqual(
getLivenessState(),
false,
'liveness should remain false after call',
);
});
it('tries three times before giving up if fetching fails', async function () {
const clock = sandbox.useFakeTimers();
fetchSwapsFeatureLivenessStub.rejects(new Error('foo'));
sandbox.spy(swapsController.store, 'updateState');
assert.strictEqual(
getLivenessState(),
false,
'liveness should be false on boot',
);
swapsController._fetchAndSetSwapsLiveness();
await clock.runAllAsync();
assert.ok(
fetchSwapsFeatureLivenessStub.calledThrice,
'should have called fetch function three times',
);
assert.ok(
swapsController.store.updateState.notCalled,
'should not have called store.updateState',
);
assert.strictEqual(
getLivenessState(),
false,
'liveness should remain false after call',
);
});
it('sets state after fetching on successful retry', async function () {
const clock = sandbox.useFakeTimers();
fetchSwapsFeatureLivenessStub.onCall(0).rejects(new Error('foo'));
fetchSwapsFeatureLivenessStub.onCall(1).rejects(new Error('foo'));
fetchSwapsFeatureLivenessStub.onCall(2).resolves(true);
assert.strictEqual(
getLivenessState(),
false,
'liveness should be false on boot',
);
swapsController._fetchAndSetSwapsLiveness();
await clock.runAllAsync();
assert.strictEqual(
fetchSwapsFeatureLivenessStub.callCount,
3,
'should have called fetch function three times',
);
assert.strictEqual(
getLivenessState(),
true,
'liveness should be true after call',
);
});
});
}); });
describe('utils', function () { describe('utils', function () {

View File

@ -3,12 +3,12 @@ module.exports = {
coverageDirectory: 'jest-coverage/', coverageDirectory: 'jest-coverage/',
coverageThreshold: { coverageThreshold: {
global: { global: {
branches: 6.3, branches: 5.83,
functions: 9.43, functions: 8.28,
lines: 8.66, lines: 11.18,
statements: 8.88, statements: 11.21,
}, },
}, },
setupFiles: ['./test/setup.js', './test/env.js'], setupFiles: ['./test/setup.js', './test/env.js'],
testMatch: ['**/ui/app/pages/swaps/**/?(*.)+(test).js'], testMatch: ['<rootDir>/ui/app/**/swaps/**/*.test.js'],
}; };

View File

@ -20,11 +20,12 @@
"forwarder": "node ./development/static-server.js ./node_modules/@metamask/forwarder/dist/ --port 9010", "forwarder": "node ./development/static-server.js ./node_modules/@metamask/forwarder/dist/ --port 9010",
"dapp-forwarder": "concurrently -k -n forwarder,dapp -p '[{time}][{name}]' 'yarn forwarder' 'yarn dapp'", "dapp-forwarder": "concurrently -k -n forwarder,dapp -p '[{time}][{name}]' 'yarn forwarder' 'yarn dapp'",
"sendwithprivatedapp": "node development/static-server.js test/e2e/send-eth-with-private-key-test --port 8080", "sendwithprivatedapp": "node development/static-server.js test/e2e/send-eth-with-private-key-test --port 8080",
"test:unit": "mocha --exit --require test/env.js --require test/setup.js --ignore './ui/app/pages/swaps/**/*.test.js' --recursive './{ui,app,shared}/**/*.test.js'", "test:unit": "mocha --exit --require test/env.js --require test/setup.js --ignore './ui/app/**/swaps/**/*.test.js' --recursive './{ui,app,shared}/**/*.test.js'",
"test:unit:global": "mocha --exit --require test/env.js --require test/setup.js --recursive test/unit-global/*.test.js", "test:unit:global": "mocha --exit --require test/env.js --require test/setup.js --recursive test/unit-global/*.test.js",
"test:unit:jest": "jest", "test:unit:jest": "jest",
"test:unit:jest:watch": "jest --watch",
"test:unit:jest:ci": "jest --maxWorkers=2", "test:unit:jest:ci": "jest --maxWorkers=2",
"test:unit:lax": "mocha --exit --require test/env.js --require test/setup.js --ignore './app/scripts/controllers/permissions/*.test.js' --ignore './ui/app/pages/swaps/**/*.test.js' --recursive './{ui,app,shared}/**/*.test.js'", "test:unit:lax": "mocha --exit --require test/env.js --require test/setup.js --ignore './app/scripts/controllers/permissions/*.test.js' --ignore './ui/app/**/swaps/**/*.test.js' --recursive './{ui,app,shared}/**/*.test.js'",
"test:unit:strict": "mocha --exit --require test/env.js --require test/setup.js --recursive './app/scripts/controllers/permissions/*.test.js'", "test:unit:strict": "mocha --exit --require test/env.js --require test/setup.js --recursive './app/scripts/controllers/permissions/*.test.js'",
"test:unit:path": "mocha --exit --require test/env.js --require test/setup.js --recursive", "test:unit:path": "mocha --exit --require test/env.js --require test/setup.js --recursive",
"test:e2e:chrome": "SELENIUM_BROWSER=chrome test/e2e/run-all.sh", "test:e2e:chrome": "SELENIUM_BROWSER=chrome test/e2e/run-all.sh",
@ -32,7 +33,7 @@
"test:e2e:firefox": "SELENIUM_BROWSER=firefox test/e2e/run-all.sh", "test:e2e:firefox": "SELENIUM_BROWSER=firefox test/e2e/run-all.sh",
"test:e2e:firefox:metrics": "SELENIUM_BROWSER=firefox mocha test/e2e/metrics.spec.js", "test:e2e:firefox:metrics": "SELENIUM_BROWSER=firefox mocha test/e2e/metrics.spec.js",
"test:coverage": "nyc --silent --check-coverage yarn test:unit:strict && nyc --silent --no-clean yarn test:unit:lax && nyc report --reporter=text --reporter=html", "test:coverage": "nyc --silent --check-coverage yarn test:unit:strict && nyc --silent --no-clean yarn test:unit:lax && nyc report --reporter=text --reporter=html",
"test:coverage:jest": "jest --coverage --maxWorkers=2 --collectCoverageFrom=**/ui/app/pages/swaps/**", "test:coverage:jest": "jest --coverage --maxWorkers=2 --collectCoverageFrom=**/ui/app/**/swaps/**",
"test:coverage:strict": "nyc --check-coverage yarn test:unit:strict", "test:coverage:strict": "nyc --check-coverage yarn test:unit:strict",
"test:coverage:path": "nyc --check-coverage yarn test:unit:path", "test:coverage:path": "nyc --check-coverage yarn test:unit:path",
"ganache:start": "./development/run-ganache.sh", "ganache:start": "./development/run-ganache.sh",

View File

@ -32,10 +32,7 @@ import {
import SwapIcon from '../../ui/icon/swap-icon.component'; import SwapIcon from '../../ui/icon/swap-icon.component';
import BuyIcon from '../../ui/icon/overview-buy-icon.component'; import BuyIcon from '../../ui/icon/overview-buy-icon.component';
import SendIcon from '../../ui/icon/overview-send-icon.component'; import SendIcon from '../../ui/icon/overview-send-icon.component';
import { import { setSwapsFromToken } from '../../../ducks/swaps/swaps';
getSwapsFeatureLiveness,
setSwapsFromToken,
} from '../../../ducks/swaps/swaps';
import IconButton from '../../ui/icon-button'; import IconButton from '../../ui/icon-button';
import WalletOverview from './wallet-overview'; import WalletOverview from './wallet-overview';
@ -73,7 +70,6 @@ const EthOverview = ({ className }) => {
properties: { source: 'Main View', active_currency: 'ETH' }, properties: { source: 'Main View', active_currency: 'ETH' },
category: 'swaps', category: 'swaps',
}); });
const swapsEnabled = useSelector(getSwapsFeatureLiveness);
const defaultSwapsToken = useSelector(getSwapsDefaultToken); const defaultSwapsToken = useSelector(getSwapsDefaultToken);
return ( return (
@ -138,34 +134,32 @@ const EthOverview = ({ className }) => {
history.push(SEND_ROUTE); history.push(SEND_ROUTE);
}} }}
/> />
{swapsEnabled ? ( <IconButton
<IconButton className="eth-overview__button"
className="eth-overview__button" disabled={!isSwapsChain}
disabled={!isSwapsChain} Icon={SwapIcon}
Icon={SwapIcon} onClick={() => {
onClick={() => { if (isSwapsChain) {
if (isSwapsChain) { enteredSwapsEvent();
enteredSwapsEvent(); dispatch(setSwapsFromToken(defaultSwapsToken));
dispatch(setSwapsFromToken(defaultSwapsToken)); if (usingHardwareWallet) {
if (usingHardwareWallet) { global.platform.openExtensionInBrowser(BUILD_QUOTE_ROUTE);
global.platform.openExtensionInBrowser(BUILD_QUOTE_ROUTE); } else {
} else { history.push(BUILD_QUOTE_ROUTE);
history.push(BUILD_QUOTE_ROUTE);
}
} }
}} }
label={t('swap')} }}
tooltipRender={(contents) => ( label={t('swap')}
<Tooltip tooltipRender={(contents) => (
title={t('onlyAvailableOnMainnet')} <Tooltip
position="bottom" title={t('onlyAvailableOnMainnet')}
disabled={isSwapsChain} position="bottom"
> disabled={isSwapsChain}
{contents} >
</Tooltip> {contents}
)} </Tooltip>
/> )}
) : null} />
</> </>
} }
className={className} className={className}

View File

@ -18,10 +18,7 @@ import {
import { useTokenTracker } from '../../../hooks/useTokenTracker'; import { useTokenTracker } from '../../../hooks/useTokenTracker';
import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount'; import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount';
import { updateSendToken } from '../../../store/actions'; import { updateSendToken } from '../../../store/actions';
import { import { setSwapsFromToken } from '../../../ducks/swaps/swaps';
getSwapsFeatureLiveness,
setSwapsFromToken,
} from '../../../ducks/swaps/swaps';
import { import {
getAssetImages, getAssetImages,
getCurrentKeyring, getCurrentKeyring,
@ -63,7 +60,6 @@ const TokenOverview = ({ className, token }) => {
properties: { source: 'Token View', active_currency: token.symbol }, properties: { source: 'Token View', active_currency: token.symbol },
category: 'swaps', category: 'swaps',
}); });
const swapsEnabled = useSelector(getSwapsFeatureLiveness);
return ( return (
<WalletOverview <WalletOverview
@ -96,41 +92,39 @@ const TokenOverview = ({ className, token }) => {
label={t('send')} label={t('send')}
data-testid="eth-overview-send" data-testid="eth-overview-send"
/> />
{swapsEnabled ? ( <IconButton
<IconButton className="token-overview__button"
className="token-overview__button" disabled={!isSwapsChain}
disabled={!isSwapsChain} Icon={SwapIcon}
Icon={SwapIcon} onClick={() => {
onClick={() => { if (isSwapsChain) {
if (isSwapsChain) { enteredSwapsEvent();
enteredSwapsEvent(); dispatch(
dispatch( setSwapsFromToken({
setSwapsFromToken({ ...token,
...token, iconUrl: assetImages[token.address],
iconUrl: assetImages[token.address], balance,
balance, string: balanceToRender,
string: balanceToRender, }),
}), );
); if (usingHardwareWallet) {
if (usingHardwareWallet) { global.platform.openExtensionInBrowser(BUILD_QUOTE_ROUTE);
global.platform.openExtensionInBrowser(BUILD_QUOTE_ROUTE); } else {
} else { history.push(BUILD_QUOTE_ROUTE);
history.push(BUILD_QUOTE_ROUTE);
}
} }
}} }
label={t('swap')} }}
tooltipRender={(contents) => ( label={t('swap')}
<Tooltip tooltipRender={(contents) => (
title={t('onlyAvailableOnMainnet')} <Tooltip
position="bottom" title={t('onlyAvailableOnMainnet')}
disabled={isSwapsChain} position="bottom"
> disabled={isSwapsChain}
{contents} >
</Tooltip> {contents}
)} </Tooltip>
/> )}
) : null} />
</> </>
} }
className={className} className={className}

View File

@ -362,6 +362,21 @@ export const fetchAndSetSwapsGasPriceInfo = () => {
}; };
}; };
export const fetchSwapsLiveness = () => {
return async (dispatch, getState) => {
let swapsFeatureIsLive = false;
try {
swapsFeatureIsLive = await fetchSwapsFeatureLiveness(
getCurrentChainId(getState()),
);
} catch (error) {
log.error('Failed to fetch Swaps liveness, defaulting to false.', error);
}
await dispatch(setSwapsLiveness(swapsFeatureIsLive));
return swapsFeatureIsLive;
};
};
export const fetchQuotesAndSetQuoteState = ( export const fetchQuotesAndSetQuoteState = (
history, history,
inputValue, inputValue,

View File

@ -0,0 +1,118 @@
import nock from 'nock';
import { setSwapsLiveness } from '../../store/actions';
import { setStorageItem } from '../../../lib/storage-helpers';
import * as swaps from './swaps';
jest.mock('../../store/actions.js', () => ({
setSwapsLiveness: jest.fn(),
}));
const providerState = {
chainId: '0x1',
nickname: '',
rpcPrefs: {},
rpcUrl: '',
ticker: 'ETH',
type: 'mainnet',
};
describe('Ducks - Swaps', () => {
afterEach(() => {
nock.cleanAll();
});
describe('fetchSwapsLiveness', () => {
const cleanFeatureFlagApiCache = () => {
setStorageItem(
'cachedFetch:https://api.metaswap.codefi.network/featureFlag',
null,
);
};
afterEach(() => {
cleanFeatureFlagApiCache();
});
const mockFeatureFlagApiResponse = ({
active = false,
replyWithError = false,
} = {}) => {
const apiNock = nock('https://api.metaswap.codefi.network').get(
'/featureFlag',
);
if (replyWithError) {
return apiNock.replyWithError({
message: 'Server error. Try again later',
code: 'serverSideError',
});
}
return apiNock.reply(200, {
active,
});
};
const createGetState = () => {
return () => ({
metamask: { provider: { ...providerState } },
});
};
it('returns true if the Swaps feature is enabled', async () => {
const mockDispatch = jest.fn();
const featureFlagApiNock = mockFeatureFlagApiResponse({ active: true });
const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock.isDone()).toBe(true);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(setSwapsLiveness).toHaveBeenCalledWith(true);
expect(isSwapsFeatureEnabled).toBe(true);
});
it('returns false if the Swaps feature is disabled', async () => {
const mockDispatch = jest.fn();
const featureFlagApiNock = mockFeatureFlagApiResponse({ active: false });
const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock.isDone()).toBe(true);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(setSwapsLiveness).toHaveBeenCalledWith(false);
expect(isSwapsFeatureEnabled).toBe(false);
});
it('returns false if the /featureFlag API call throws an error', async () => {
const mockDispatch = jest.fn();
const featureFlagApiNock = mockFeatureFlagApiResponse({
replyWithError: true,
});
const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock.isDone()).toBe(true);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(setSwapsLiveness).toHaveBeenCalledWith(false);
expect(isSwapsFeatureEnabled).toBe(false);
});
it('only calls the API once and returns true from cache for the second call', async () => {
const mockDispatch = jest.fn();
const featureFlagApiNock = mockFeatureFlagApiResponse({ active: true });
await swaps.fetchSwapsLiveness()(mockDispatch, createGetState());
expect(featureFlagApiNock.isDone()).toBe(true);
const featureFlagApiNock2 = mockFeatureFlagApiResponse({ active: true });
const isSwapsFeatureEnabled = await swaps.fetchSwapsLiveness()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock2.isDone()).toBe(false); // Second API call wasn't made, cache was used instead.
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(setSwapsLiveness).toHaveBeenCalledWith(true);
expect(isSwapsFeatureEnabled).toBe(true);
});
});
});

View File

@ -75,6 +75,7 @@ export default function BuildQuote({
setMaxSlippage, setMaxSlippage,
maxSlippage, maxSlippage,
selectedAccountAddress, selectedAccountAddress,
isFeatureFlagLoaded,
}) { }) {
const t = useContext(I18nContext); const t = useContext(I18nContext);
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -529,6 +530,7 @@ export default function BuildQuote({
}} }}
submitText={t('swapReviewSwap')} submitText={t('swapReviewSwap')}
disabled={ disabled={
!isFeatureFlagLoaded ||
!Number(inputValue) || !Number(inputValue) ||
!selectedToToken?.address || !selectedToToken?.address ||
Number(maxSlippage) === 0 || Number(maxSlippage) === 0 ||
@ -549,4 +551,5 @@ BuildQuote.propTypes = {
ethBalance: PropTypes.string, ethBalance: PropTypes.string,
setMaxSlippage: PropTypes.func, setMaxSlippage: PropTypes.func,
selectedAccountAddress: PropTypes.string, selectedAccountAddress: PropTypes.string,
isFeatureFlagLoaded: PropTypes.bool.isRequired,
}; };

View File

@ -30,6 +30,7 @@ import {
getSwapsFeatureLiveness, getSwapsFeatureLiveness,
prepareToLeaveSwaps, prepareToLeaveSwaps,
fetchAndSetSwapsGasPriceInfo, fetchAndSetSwapsGasPriceInfo,
fetchSwapsLiveness,
} from '../../ducks/swaps/swaps'; } from '../../ducks/swaps/swaps';
import { import {
AWAITING_SWAP_ROUTE, AWAITING_SWAP_ROUTE,
@ -85,6 +86,7 @@ export default function Swap() {
const [inputValue, setInputValue] = useState(fetchParams?.value || ''); const [inputValue, setInputValue] = useState(fetchParams?.value || '');
const [maxSlippage, setMaxSlippage] = useState(fetchParams?.slippage || 3); const [maxSlippage, setMaxSlippage] = useState(fetchParams?.slippage || 3);
const [isFeatureFlagLoaded, setIsFeatureFlagLoaded] = useState(false);
const routeState = useSelector(getBackgroundSwapRouteState); const routeState = useSelector(getBackgroundSwapRouteState);
const selectedAccount = useSelector(getSelectedAccount); const selectedAccount = useSelector(getSelectedAccount);
@ -200,10 +202,15 @@ export default function Swap() {
}); });
useEffect(() => { useEffect(() => {
const fetchSwapsLivenessWrapper = async () => {
await dispatch(fetchSwapsLiveness());
setIsFeatureFlagLoaded(true);
};
fetchSwapsLivenessWrapper();
return () => { return () => {
exitEventRef.current(); exitEventRef.current();
}; };
}, []); }, [dispatch]);
useEffect(() => { useEffect(() => {
if (swapsErrorKey && !isSwapsErrorRoute) { if (swapsErrorKey && !isSwapsErrorRoute) {
@ -283,6 +290,7 @@ export default function Swap() {
setMaxSlippage={setMaxSlippage} setMaxSlippage={setMaxSlippage}
selectedAccountAddress={selectedAccountAddress} selectedAccountAddress={selectedAccountAddress}
maxSlippage={maxSlippage} maxSlippage={maxSlippage}
isFeatureFlagLoaded={isFeatureFlagLoaded}
/> />
); );
}} }}