diff --git a/.detoxrc.js b/.detoxrc.js
index b1a80212b46..46f0b4441e1 100644
--- a/.detoxrc.js
+++ b/.detoxrc.js
@@ -1,7 +1,7 @@
/** @type {Detox.DetoxConfig} */
module.exports = {
artifacts: {
- rootDir: "./artifacts/screenshots",
+ rootDir: "./artifacts",
plugins: {
screenshot: {
shouldTakeAutomaticSnapshots: true,
@@ -9,10 +9,15 @@ module.exports = {
takeWhen: {
testStart: false,
testDone: false,
- }
+ },
+ },
+ video: {
+ enabled: true, // Enable video recording
+ keepOnlyFailedTestsArtifacts: true, // Keep only failed tests' videos
},
},
},
+
testRunner: {
args: {
$0: 'jest',
diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
index 5f70f739948..17e68d679a7 100644
--- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
+++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js
@@ -447,6 +447,7 @@ export class NetworkSettings extends PureComponent {
blockExplorerUrls: [],
selectedRpcEndpointIndex: 0,
blockExplorerUrl: undefined,
+ blockExplorerUrlForm: undefined,
nickname: undefined,
chainId: undefined,
ticker: undefined,
@@ -714,7 +715,11 @@ export class NetworkSettings extends PureComponent {
// in an error message in the form.
if (!formChainId.startsWith('0x')) {
try {
- endpointChainId = new BigNumber(endpointChainId, 16).toString(10);
+ const endpointChainIdNumber = new BigNumber(endpointChainId, 16);
+ if (endpointChainIdNumber.isNaN()) {
+ throw new Error('Invalid endpointChainId');
+ }
+ endpointChainId = endpointChainIdNumber.toString(10);
} catch (err) {
Logger.error(err, {
endpointChainId,
@@ -1288,10 +1293,21 @@ export class NetworkSettings extends PureComponent {
};
onBlockExplorerItemAdd = async (url) => {
+ // If URL is empty or undefined, return early
if (!url) {
return;
}
+ // Check if the URL already exists in blockExplorerUrls
+ const { blockExplorerUrls } = this.state;
+ const urlExists = blockExplorerUrls.includes(url);
+
+ if (urlExists) {
+ // If the URL already exists, return early
+ return;
+ }
+
+ // If the URL doesn't exist, proceed with adding it
await this.setState((prevState) => ({
blockExplorerUrls: [...prevState.blockExplorerUrls, url],
}));
@@ -1351,6 +1367,7 @@ export class NetworkSettings extends PureComponent {
onBlockExplorerUrlChange = async (url) => {
const { addMode } = this.state;
await this.setState({
+ blockExplorerUrlForm: url,
blockExplorerUrl: url,
});
@@ -1486,7 +1503,10 @@ export class NetworkSettings extends PureComponent {
};
closeAddBlockExplorerRpcForm = () => {
- this.setState({ showAddBlockExplorerForm: { isVisible: false } });
+ this.setState({
+ showAddBlockExplorerForm: { isVisible: false },
+ blockExplorerUrlForm: undefined,
+ });
};
closeRpcModal = () => {
@@ -1603,6 +1623,7 @@ export class NetworkSettings extends PureComponent {
rpcUrlForm,
rpcNameForm,
rpcName,
+ blockExplorerUrlForm,
} = this.state;
const { route, networkConfigurations } = this.props;
const isCustomMainnet = route.params?.isCustomMainnet;
@@ -2204,6 +2225,7 @@ export class NetworkSettings extends PureComponent {
ref={this.inputBlockExplorerURL}
style={inputStyle}
autoCapitalize={'none'}
+ value={blockExplorerUrlForm}
autoCorrect={false}
onChangeText={this.onBlockExplorerUrlChange}
placeholder={strings(
@@ -2214,23 +2236,28 @@ export class NetworkSettings extends PureComponent {
onSubmitEditing={this.toggleNetworkDetailsModal}
keyboardAppearance={themeAppearance}
/>
- {blockExplorerUrl && !isUrl(blockExplorerUrl) && (
-
+ {blockExplorerUrl &&
+ (!isUrl(blockExplorerUrl) ||
+ blockExplorerUrls.includes(blockExplorerUrlForm)) && (
{strings('app_settings.invalid_block_explorer_url')}
-
- )}
+ )}
+
{
- this.onBlockExplorerItemAdd(blockExplorerUrl);
+ this.onBlockExplorerItemAdd(blockExplorerUrlForm);
}}
width={ButtonWidthTypes.Full}
labelTextVariant={TextVariant.DisplayMD}
- isDisabled={!blockExplorerUrl || !isUrl(blockExplorerUrl)}
+ isDisabled={
+ !blockExplorerUrl ||
+ !blockExplorerUrlForm ||
+ !isUrl(blockExplorerUrl)
+ }
/>
diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
index 1ecffde7034..7f55c1d687c 100644
--- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
+++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx
@@ -8,6 +8,9 @@ import { ThemeContext, mockTheme } from '../../../../../../app/util/theme';
import { backgroundState } from '../../../../../util/test/initial-root-state';
import { isNetworkUiRedesignEnabled } from '../../../../../util/networks/isNetworkUiRedesignEnabled';
import { mockNetworkState } from '../../../../../util/test/network';
+// eslint-disable-next-line import/no-namespace
+import * as jsonRequest from '../../../../../util/jsonRpcRequest';
+import Logger from '../../../../../util/Logger';
import Engine from '../../../../../core/Engine';
// Mock the entire module
@@ -87,6 +90,7 @@ const SAMPLE_NETWORKSETTINGS_PROPS = {
rpcEndpoints: [{ url: 'https://goerli.infura.io/v3/{infuraProjectId}' }],
},
},
+ networkOnboardedState: { '0x1': true, '0xe708': true },
navigation: { setOptions: jest.fn(), navigate: jest.fn(), goBack: jest.fn() },
matchedChainNetwork: {
safeChainsList: [
@@ -756,6 +760,49 @@ describe('NetworkSettings', () => {
expect(wrapper.state('warningRpcUrl')).toBe('Invalid RPC URL');
});
+ it('should set a warning if the RPC URL format is invalid', async () => {
+ const instance = wrapper.instance();
+
+ await instance.validateRpcUrl('invalidUrl');
+ expect(wrapper.state('warningRpcUrl')).toBe(
+ 'URIs require the appropriate HTTPS prefix',
+ );
+ });
+
+ it('should set a warning for a duplicated RPC URL', async () => {
+ const instance = wrapper.instance();
+
+ await instance.validateRpcUrl(
+ 'https://mainnet.infura.io/v3/YOUR-PROJECT-ID',
+ );
+ expect(wrapper.state('warningRpcUrl')).toBe('Invalid RPC URL');
+ });
+
+ it('should set a warning if the RPC URL already exists in networkConfigurations and UI redesign is disabled', async () => {
+ (isNetworkUiRedesignEnabled as jest.Mock).mockImplementation(() => false);
+ const instance = wrapper.instance();
+
+ await instance.validateRpcUrl(
+ 'https://mainnet.infura.io/v3/YOUR-PROJECT-ID',
+ );
+ await instance.validateRpcUrl(
+ 'https://mainnet.infura.io/v3/YOUR-PROJECT-ID',
+ );
+ expect(wrapper.state('warningRpcUrl')).toBe('Invalid RPC URL');
+ expect(wrapper.state('validatedRpcURL')).toBe(true);
+ });
+
+ it('should set a warning if the RPC URL exists and UI redesign is enabled', async () => {
+ (isNetworkUiRedesignEnabled as jest.Mock).mockImplementation(() => true);
+ const instance = wrapper.instance();
+
+ await instance.validateRpcUrl(
+ 'https://mainnet.infura.io/v3/YOUR-PROJECT-ID',
+ );
+ expect(wrapper.state('warningRpcUrl')).toBe('Invalid RPC URL');
+ expect(wrapper.state('validatedRpcURL')).toBe(true);
+ });
+
it('should correctly add RPC URL through modal and update state', async () => {
const instance = wrapper.instance();
@@ -781,6 +828,47 @@ describe('NetworkSettings', () => {
);
});
+ it('should not add an empty Block Explorer URL and should return early', async () => {
+ const instance = wrapper.instance();
+
+ // Initially, blockExplorerUrls should be empty
+ expect(wrapper.state('blockExplorerUrls').length).toBe(0);
+
+ // Open Block Explorer form modal and attempt to add an empty URL
+ instance.openAddBlockExplorerForm();
+ await instance.onBlockExplorerItemAdd('');
+
+ // Ensure the state is not updated with the empty URL
+ expect(wrapper.state('blockExplorerUrls').length).toBe(0);
+ expect(wrapper.state('blockExplorerUrl')).toBeUndefined();
+ });
+
+ it('should not add an existing Block Explorer URL and should return early', async () => {
+ const instance = wrapper.instance();
+
+ // Set initial state with an existing block explorer URL
+ await instance.setState({
+ blockExplorerUrls: ['https://existing-blockexplorer.com'],
+ });
+
+ // Ensure the initial state contains the existing URL
+ expect(wrapper.state('blockExplorerUrls').length).toBe(1);
+ expect(wrapper.state('blockExplorerUrls')[0]).toBe(
+ 'https://existing-blockexplorer.com',
+ );
+
+ // Attempt to add the same URL again
+ await instance.onBlockExplorerItemAdd(
+ 'https://existing-blockexplorer.com',
+ );
+
+ // Ensure the state remains unchanged and no duplicate is added
+ expect(wrapper.state('blockExplorerUrls').length).toBe(1);
+ expect(wrapper.state('blockExplorerUrls')[0]).toBe(
+ 'https://existing-blockexplorer.com',
+ );
+ });
+
it('should call validateRpcAndChainId when chainId and rpcUrl are set', async () => {
const instance = wrapper.instance();
const validateRpcAndChainIdSpy = jest.spyOn(
@@ -885,7 +973,7 @@ describe('NetworkSettings', () => {
it('should handle valid chainId conversion and updating state correctly', async () => {
const instance = wrapper.instance();
- await instance.onChainIDChange('0x1');
+ await instance.onChainIDChange('0x2');
await instance.validateChainId();
expect(wrapper.state('warningChainId')).toBe(undefined);
@@ -1355,4 +1443,374 @@ describe('NetworkSettings', () => {
]);
});
});
+
+ describe('templateInfuraRpc', () => {
+ it('should not replace anything if {infuraProjectId} is not in endpoint', () => {
+ const instance = wrapper.instance();
+
+ const endpoint = 'https://mainnet.infura.io/v3/someOtherId';
+ const result = instance.templateInfuraRpc(endpoint);
+ expect(result).toBe('https://mainnet.infura.io/v3/someOtherId');
+ });
+
+ it('should replace {infuraProjectId} with an empty string if infuraProjectId is undefined', () => {
+ const instance = wrapper.instance();
+ const endpoint = 'https://mainnet.infura.io/v3/{infuraProjectId}';
+ const result = instance.templateInfuraRpc(endpoint);
+ expect(result).toBe('https://mainnet.infura.io/v3/');
+ });
+
+ it('should return the original endpoint if it does not end with {infuraProjectId}', () => {
+ const instance = wrapper.instance();
+ const endpoint = 'https://mainnet.infura.io/v3/anotherProjectId';
+ const result = instance.templateInfuraRpc(endpoint);
+ expect(result).toBe(endpoint);
+ });
+ });
+
+ describe('validateChainIdOnSubmit', () => {
+ beforeEach(() => {
+ // Spying on the methods we want to mock
+ jest.spyOn(Logger, 'error'); // Spy on Logger.error
+ jest.spyOn(jsonRequest, 'jsonRpcRequest'); // Spy on jsonRpcRequest directly
+ });
+ afterEach(() => {
+ jest.resetAllMocks(); // Clean up mocks after each test
+ });
+
+ it('should validate chainId when parsedChainId matches endpoint chainId', async () => {
+ const instance = wrapper.instance();
+
+ (jsonRequest.jsonRpcRequest as jest.Mock).mockResolvedValue('0x38');
+
+ const validChainId = '0x38';
+ const rpcUrl = 'https://bsc-dataseed.binance.org/';
+
+ await instance.validateChainIdOnSubmit(
+ validChainId,
+ validChainId,
+ rpcUrl,
+ );
+
+ expect(instance.state.warningChainId).toBeUndefined();
+ expect(jsonRequest.jsonRpcRequest).toHaveBeenCalledWith(
+ 'https://bsc-dataseed.binance.org/',
+ 'eth_chainId',
+ );
+ });
+
+ it('should set a warning when chainId is invalid (RPC error)', async () => {
+ const instance = wrapper.instance();
+
+ (jsonRequest.jsonRpcRequest as jest.Mock).mockRejectedValue(
+ new Error('RPC error'),
+ );
+
+ const invalidChainId = '0xInvalidChainId';
+ const rpcUrl = 'https://bsc-dataseed.binance.org/';
+
+ await instance.validateChainIdOnSubmit(
+ invalidChainId,
+ invalidChainId,
+ rpcUrl,
+ );
+
+ expect(instance.state.warningChainId).toBe(
+ 'Could not fetch chain ID. Is your RPC URL correct?',
+ );
+ expect(Logger.error).toHaveBeenCalled(); // Ensures the error is logged
+ });
+
+ it('should set a warning when parsedChainId does not match endpoint chainId', async () => {
+ const instance = wrapper.instance();
+
+ (jsonRequest.jsonRpcRequest as jest.Mock).mockResolvedValue('0x39');
+
+ const validChainId = '0x38';
+ const rpcUrl = 'https://bsc-dataseed.binance.org/';
+
+ await instance.validateChainIdOnSubmit(
+ validChainId,
+ validChainId,
+ rpcUrl,
+ );
+
+ expect(instance.state.warningChainId).toBe(
+ 'The endpoint returned a different chain ID: 0x39',
+ );
+ });
+
+ it('should convert endpointChainId to decimal if formChainId is decimal and not hexadecimal', async () => {
+ const instance = wrapper.instance();
+
+ (jsonRequest.jsonRpcRequest as jest.Mock).mockResolvedValue('0x38');
+
+ const decimalChainId = '56'; // Decimal chain ID
+ const rpcUrl = 'https://bsc-dataseed.binance.org/';
+
+ await instance.validateChainIdOnSubmit(
+ decimalChainId,
+ decimalChainId,
+ rpcUrl,
+ );
+
+ expect(instance.state.warningChainId).toBe(
+ 'The endpoint returned a different chain ID: 56',
+ );
+ });
+
+ it('should log error if the conversion from hexadecimal to decimal fails', async () => {
+ const instance = wrapper.instance();
+
+ (jsonRequest.jsonRpcRequest as jest.Mock).mockResolvedValue(
+ '0xInvalidHex',
+ );
+
+ const decimalChainId = 'test'; // Invalid decimal chain ID
+ const rpcUrl = 'https://bsc-dataseed.binance.org/';
+
+ await instance.validateChainIdOnSubmit(
+ decimalChainId,
+ decimalChainId,
+ rpcUrl,
+ );
+
+ expect(Logger.error).toHaveBeenCalledWith(expect.any(Error), {
+ endpointChainId: '0xInvalidHex',
+ message: 'Failed to convert endpoint chain ID to decimal',
+ });
+ });
+ });
+
+ describe('addRpcUrl', () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let instance: any;
+
+ beforeEach(() => {
+ instance = wrapper.instance();
+ (isNetworkUiRedesignEnabled as jest.Mock).mockImplementation(() => true);
+
+ // Mocking dependent methods
+ jest.spyOn(instance, 'disabledByChainId').mockReturnValue(false);
+ jest.spyOn(instance, 'disabledBySymbol').mockReturnValue(false);
+ jest
+ .spyOn(instance, 'checkIfNetworkNotExistsByChainId')
+ .mockResolvedValue([]);
+ jest.spyOn(instance, 'checkIfNetworkExists').mockResolvedValue(false);
+ jest.spyOn(instance, 'validateChainIdOnSubmit').mockResolvedValue(true);
+ jest.spyOn(instance, 'handleNetworkUpdate').mockResolvedValue({});
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should add RPC URL correctly', async () => {
+ wrapper.setState({
+ rpcUrl: 'http://localhost:8545',
+ chainId: '0x1',
+ ticker: 'ETH',
+ nickname: 'Localhost',
+ enableAction: true,
+ addMode: true,
+ editable: false,
+ });
+
+ await instance.addRpcUrl();
+
+ expect(instance.handleNetworkUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ rpcUrl: 'http://localhost:8545',
+ chainId: '0x1',
+ ticker: 'ETH',
+ nickname: 'Localhost',
+ }),
+ );
+ });
+
+ it('should return early if CTA is disabled by enableAction', async () => {
+ wrapper.setState({ enableAction: false });
+
+ await instance.addRpcUrl();
+
+ expect(instance.handleNetworkUpdate).not.toHaveBeenCalled();
+ });
+
+ it('should return early if CTA is disabled by chainId', async () => {
+ instance.disabledByChainId.mockReturnValue(true);
+
+ await instance.addRpcUrl();
+
+ expect(instance.handleNetworkUpdate).not.toHaveBeenCalled();
+ });
+
+ it('should return early if CTA is disabled by symbol', async () => {
+ instance.disabledBySymbol.mockReturnValue(true);
+
+ await instance.addRpcUrl();
+
+ expect(instance.handleNetworkUpdate).not.toHaveBeenCalled();
+ });
+
+ it('should not proceed if validateChainIdOnSubmit fails', async () => {
+ instance.validateChainIdOnSubmit.mockResolvedValue(false);
+
+ await instance.addRpcUrl();
+
+ expect(instance.handleNetworkUpdate).not.toHaveBeenCalled();
+ });
+
+ it('should check if network already exists in add mode', async () => {
+ wrapper.setState({ addMode: true, chainId: '0x1', enableAction: true });
+
+ await instance.addRpcUrl();
+
+ expect(instance.checkIfNetworkNotExistsByChainId).toHaveBeenCalledWith(
+ '0x1',
+ );
+ expect(instance.checkIfNetworkExists).not.toHaveBeenCalled();
+ });
+
+ it('should check if network exists in edit mode', async () => {
+ (isNetworkUiRedesignEnabled as jest.Mock).mockImplementation(() => false);
+
+ wrapper.setState({
+ chainId: '0x1',
+ editable: false,
+ rpcUrl: 'http://localhost:8545',
+ enableAction: true,
+ });
+
+ await instance.addRpcUrl();
+
+ expect(instance.checkIfNetworkExists).toHaveBeenCalledWith(
+ 'http://localhost:8545',
+ );
+ expect(instance.checkIfNetworkNotExistsByChainId).not.toHaveBeenCalled();
+ });
+
+ it('should handle custom mainnet condition', async () => {
+ wrapper.setProps({
+ route: {
+ params: {
+ isCustomMainnet: true,
+ },
+ },
+ });
+
+ wrapper.setState({
+ rpcUrl: 'http://localhost:8545',
+ chainId: '0x1',
+ ticker: 'ETH',
+ nickname: 'Localhost',
+ enableAction: true,
+ addMode: true,
+ editable: false,
+ });
+
+ await instance.addRpcUrl();
+
+ expect(instance.handleNetworkUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isCustomMainnet: true,
+ showNetworkOnboarding: false,
+ }),
+ );
+ });
+
+ it('should handle network switch pop to wallet condition', async () => {
+ wrapper.setProps({
+ route: {
+ params: {
+ shouldNetworkSwitchPopToWallet: false,
+ },
+ },
+ });
+
+ wrapper.setState({
+ rpcUrl: 'http://localhost:8545',
+ chainId: '0x1',
+ ticker: 'ETH',
+ nickname: 'Localhost',
+ enableAction: true,
+ addMode: true,
+ editable: false,
+ });
+
+ await instance.addRpcUrl();
+
+ expect(instance.handleNetworkUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ shouldNetworkSwitchPopToWallet: false,
+ }),
+ );
+ });
+ });
+
+ describe('checkIfNetworkExists', () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let instance: any;
+
+ beforeEach(() => {
+ instance = wrapper.instance();
+
+ jest.spyOn(instance, 'setState');
+ (isNetworkUiRedesignEnabled as jest.Mock).mockImplementation(() => true);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks(); // Clear all spies after each test
+ });
+
+ it('should return custom network if rpcUrl exists in networkConfigurations and UI redesign is disabled', async () => {
+ (isNetworkUiRedesignEnabled as jest.Mock).mockImplementation(() => false);
+
+ const rpcUrl = 'http://localhost:8545';
+
+ // Mocking props
+ wrapper.setProps({
+ networkConfigurations: {
+ customNetwork1: { rpcUrl },
+ },
+ });
+
+ const result = await instance.checkIfNetworkExists(rpcUrl);
+
+ expect(result).toEqual([{ rpcUrl }]);
+ expect(instance.setState).toHaveBeenCalledWith({
+ warningRpcUrl: 'This network has already been added.',
+ });
+ });
+
+ it('should return custom network if rpcUrl exists in networkConfigurations and UI redesign is enabled', async () => {
+ const rpcUrl = 'http://localhost:8545';
+
+ // Mocking props and enabling network UI redesign
+ wrapper.setProps({
+ networkConfigurations: {
+ customNetwork1: { rpcUrl },
+ },
+ });
+
+ const result = await instance.checkIfNetworkExists(rpcUrl);
+
+ expect(result).toEqual([{ rpcUrl }]);
+ expect(instance.setState).not.toHaveBeenCalled(); // Should not set warning when redesign is enabled
+ });
+
+ it('should return an empty array if rpcUrl does not exist in any networks', async () => {
+ const rpcUrl = 'https://nonexistent.rpc.url';
+
+ // Mocking props
+ wrapper.setProps({
+ networkConfigurations: {
+ customNetwork1: { rpcUrl: 'http://localhost:8545' },
+ },
+ });
+
+ const result = await instance.checkIfNetworkExists(rpcUrl);
+
+ expect(result).toEqual([]);
+ });
+ });
});
diff --git a/bitrise.yml b/bitrise.yml
index 070b13b5846..3b47f2039d6 100644
--- a/bitrise.yml
+++ b/bitrise.yml
@@ -746,7 +746,7 @@ workflows:
- content: |-
#!/usr/bin/env bash
set -ex
- cp -r "$BITRISE_SOURCE_DIR/artifacts/screenshots" "$BITRISE_DEPLOY_DIR"
+ cp -r "$BITRISE_SOURCE_DIR/artifacts" "$BITRISE_DEPLOY_DIR"
- deploy-to-bitrise-io@2.3:
title: Deploy test screenshots
is_always_run: true
@@ -754,7 +754,7 @@ workflows:
inputs:
- deploy_path: $BITRISE_DEPLOY_DIR
- is_compress: true
- - zip_name: E2E_Android_Failure_Screenshots
+ - zip_name: E2E_Android_Failure_Artifacts
meta:
bitrise.io:
machine_type_id: elite-xl
@@ -1004,7 +1004,7 @@ workflows:
- content: |-
#!/usr/bin/env bash
set -ex
- cp -r "$BITRISE_SOURCE_DIR/artifacts/screenshots" "$BITRISE_DEPLOY_DIR"
+ cp -r "$BITRISE_SOURCE_DIR/artifacts" "$BITRISE_DEPLOY_DIR"
- deploy-to-bitrise-io@2.3:
is_always_run: true
run_if: .IsBuildFailed
@@ -1012,7 +1012,7 @@ workflows:
inputs:
- deploy_path: $BITRISE_DEPLOY_DIR
- is_compress: true
- - zip_name: 'E2E_IOS_Failure_Screenshots'
+ - zip_name: 'E2E_IOS_Failure_Artifacts'
start_e2e_tests:
steps:
- build-router-start@0: