From 6bc6cba6d5ef14cd5c16ab4e7d147611b9328344 Mon Sep 17 00:00:00 2001 From: jainex Date: Mon, 19 Jun 2023 23:29:52 +0530 Subject: [PATCH 001/118] remove Unused CSS (#19602) Co-authored-by: Brad Decker Co-authored-by: Nidhi Kumari --- ui/components/app/app-components.scss | 1 - .../app/connected-status-indicator/index.scss | 57 ------------------- 2 files changed, 58 deletions(-) delete mode 100644 ui/components/app/connected-status-indicator/index.scss diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss index 036152c71..6d3ea4af4 100644 --- a/ui/components/app/app-components.scss +++ b/ui/components/app/app-components.scss @@ -19,7 +19,6 @@ @import 'connected-accounts-list/index'; @import 'connected-accounts-permissions/index'; @import 'connected-sites-list/index'; -@import 'connected-status-indicator/index'; @import 'create-new-vault/create-new-vault.scss'; @import 'custom-spending-cap/index'; @import 'edit-gas-display/index'; diff --git a/ui/components/app/connected-status-indicator/index.scss b/ui/components/app/connected-status-indicator/index.scss deleted file mode 100644 index 3b18f16a4..000000000 --- a/ui/components/app/connected-status-indicator/index.scss +++ /dev/null @@ -1,57 +0,0 @@ -.connected-status-indicator { - display: flex; - align-items: center; - place-self: center start; - background: none; - font-size: inherit; - padding: 8px; - border-radius: 100px; - - &:hover, - &:active { - background-color: var(--color-background-default-hover); - } - - &__inner-circle { - border-radius: 4px; - height: 4px; - width: 4px; - background-color: transparent; - } - - &__green-circle, - &__yellow-circle, - &__grey-circle { - border-radius: 4px; - height: 8px; - width: 8px; - border: 1px solid transparent; - display: flex; - align-items: center; - justify-content: center; - } - - &__green-circle { - border-color: var(--color-success-default); - } - - &__green-circle &__inner-circle { - background-color: var(--color-success-default); - } - - &__yellow-circle { - border-color: var(--color-warning-default); - } - - &__grey-circle { - border-color: var(--color-border-default); - } - - &__text { - @include H8; - - color: var(--color-text-alternative); - margin-left: 6px; - white-space: nowrap; - } -} From 8a85d220e2ea5b3283589e204a02456141d60fdf Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Mon, 19 Jun 2023 16:14:08 -0230 Subject: [PATCH 002/118] Fix `testDev` builds with LavaMoat enabled (#19506) * Fix `devTest` build that have LavaMoat enabled The build script compiles the LavaMoat runtime during each root task, but it was not recognizing `testDev` as a root task. As a result, all `testDev` builds were broken unless LavaMoat was disabled. The list of root tasks has been updated to include `testDev`. It should now be complete. * Use BUILD_TARGETS constant, and enable scuttling in start:test --- development/build/index.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/development/build/index.js b/development/build/index.js index b9d00698a..c2fdf5095 100755 --- a/development/build/index.js +++ b/development/build/index.js @@ -14,7 +14,7 @@ const difference = require('lodash/difference'); const { intersection } = require('lodash'); const { getVersion } = require('../lib/get-version'); const { loadBuildTypesConfig } = require('../lib/build-type'); -const { TASKS } = require('./constants'); +const { BUILD_TARGETS, TASKS } = require('./constants'); const { createTask, composeSeries, @@ -28,7 +28,6 @@ const createStaticAssetTasks = require('./static'); const createEtcTasks = require('./etc'); const { getBrowserVersionMap, getEnvironment } = require('./utils'); const { getConfig } = require('./config'); -const { BUILD_TARGETS } = require('./constants'); // Packages required dynamically via browserify configuration in dependencies // Required for LavaMoat policy generation @@ -76,11 +75,11 @@ async function defineAndRunBuildTasks() { platform, } = await parseArgv(); - const isRootTask = ['dist', 'prod', 'test', 'dev'].includes(entryTask); + const isRootTask = Object.values(BUILD_TARGETS).includes(entryTask); if (isRootTask) { // scuttle on production/tests environment only - const shouldScuttle = ['dist', 'prod', 'test'].includes(entryTask); + const shouldScuttle = entryTask !== BUILD_TARGETS.DEV; console.log( `Building lavamoat runtime file`, From 66c96542440a3f63b229487b4811d9f06f57d5cd Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Tue, 20 Jun 2023 11:17:08 +0100 Subject: [PATCH 003/118] Implement tests for multiple service worker restarts on the mv3 build (#19293) * implement multiple restart tests * remove console.logs * fix * wip * wip * wip * wip * wip * wip * wip * wip * close stale prs * revert chromedriver version * delete code leftover * remove unlockWallet method --------- Co-authored-by: Brad Decker --- development/build/index.js | 1 + test/e2e/helpers.js | 96 +++- test/e2e/mv3/multiple-restarts.spec.js | 427 ++++++++++++++++++ .../mv3/phishing-warning-sw-restart.spec.js | 13 +- test/e2e/tests/eth-sign.spec.js | 6 +- test/e2e/webdriver/driver.js | 5 + yarn.lock | 12 +- 7 files changed, 535 insertions(+), 25 deletions(-) create mode 100644 test/e2e/mv3/multiple-restarts.spec.js diff --git a/development/build/index.js b/development/build/index.js index c2fdf5095..1ca565043 100755 --- a/development/build/index.js +++ b/development/build/index.js @@ -126,6 +126,7 @@ async function defineAndRunBuildTasks() { 'Promise', 'JSON', 'Date', + 'Proxy', // globals sentry needs to function '__SENTRY__', 'appState', diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index f70d9d51a..51d333062 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -530,32 +530,97 @@ const locateAccountBalanceDOM = async (driver, ganacheServer) => { text: `${balance} ETH`, }); }; +const DEFAULT_PRIVATE_KEY = + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC'; +const WALLET_PASSWORD = 'correct horse battery staple'; -const restartServiceWorker = async (driver) => { - const serviceWorkerElements = await driver.findElements({ - text: 'terminate', - tag: 'span', - }); - // 1st one is app-init.js; while 2nd one is service-worker.js - await serviceWorkerElements[1].click(); +const DEFAULT_GANACHE_OPTIONS = { + accounts: [ + { + secretKey: DEFAULT_PRIVATE_KEY, + balance: generateETHBalance(25), + }, + ], }; +const generateGanacheOptions = (overrides) => ({ + ...DEFAULT_GANACHE_OPTIONS, + ...overrides, +}); + async function waitForAccountRendered(driver) { await driver.waitForSelector( '[data-testid="eth-overview__primary-currency"]', ); } +const WINDOW_TITLES = Object.freeze({ + ExtensionInFullScreenView: 'MetaMask', + TestDApp: 'E2E Test Dapp', + Notification: 'MetaMask Notification', + ServiceWorkerSettings: 'Inspect with Chrome Developer Tools', + InstalledExtensions: 'Extensions', +}); -const login = async (driver) => { +const unlockWallet = async (driver) => { await driver.fill('#password', 'correct horse battery staple'); await driver.press('#password', driver.Key.ENTER); }; const logInWithBalanceValidation = async (driver, ganacheServer) => { - await login(driver); + await unlockWallet(driver); await assertAccountBalanceForDOM(driver, ganacheServer); }; +function roundToXDecimalPlaces(number, decimalPlaces) { + return Math.round(number * 10 ** decimalPlaces) / 10 ** decimalPlaces; +} + +function generateRandNumBetween(x, y) { + const min = Math.min(x, y); + const max = Math.max(x, y); + const randomNumber = Math.random() * (max - min) + min; + + return randomNumber; +} + +async function switchToWindow(driver, windowTitle) { + const windowHandles = await driver.getAllWindowHandles(); + + return await driver.switchToWindowWithTitle(windowTitle, windowHandles); +} + +async function sleepSeconds(sec) { + return new Promise((resolve) => setTimeout(resolve, sec * 1000)); +} + +async function terminateServiceWorker(driver) { + await driver.openNewPage(SERVICE_WORKER_URL); + + await driver.waitForSelector({ + text: 'Service workers', + tag: 'button', + }); + await driver.clickElement({ + text: 'Service workers', + tag: 'button', + }); + + const serviceWorkerElements = await driver.findElements({ + text: 'terminate', + tag: 'span', + }); + + // 1st one is app-init.js; while 2nd one is service-worker.js + await serviceWorkerElements[serviceWorkerElements.length - 1].click(); + + const serviceWorkerTab = await switchToWindow( + driver, + WINDOW_TITLES.ServiceWorkerSettings, + ); + + await driver.closeWindowHandle(serviceWorkerTab); +} + module.exports = { DAPP_URL, DAPP_ONE_URL, @@ -583,10 +648,19 @@ module.exports = { defaultGanacheOptions, sendTransaction, findAnotherAccountFromAccountList, - login, + unlockWallet, logInWithBalanceValidation, assertAccountBalanceForDOM, locateAccountBalanceDOM, - restartServiceWorker, waitForAccountRendered, + generateGanacheOptions, + WALLET_PASSWORD, + WINDOW_TITLES, + DEFAULT_GANACHE_OPTIONS, + generateETHBalance, + roundToXDecimalPlaces, + generateRandNumBetween, + switchToWindow, + sleepSeconds, + terminateServiceWorker, }; diff --git a/test/e2e/mv3/multiple-restarts.spec.js b/test/e2e/mv3/multiple-restarts.spec.js new file mode 100644 index 000000000..91f34896d --- /dev/null +++ b/test/e2e/mv3/multiple-restarts.spec.js @@ -0,0 +1,427 @@ +const { strict: assert } = require('assert'); +const { + withFixtures, + openDapp, + generateGanacheOptions, + WALLET_PASSWORD, + WINDOW_TITLES, + DEFAULT_GANACHE_OPTIONS, + generateETHBalance, + roundToXDecimalPlaces, + generateRandNumBetween, + switchToWindow, + sleepSeconds, + terminateServiceWorker, + unlockWallet, +} = require('../helpers'); +const FixtureBuilder = require('../fixture-builder'); + +describe('MV3 - Restart service worker multiple times', function () { + it('Simple simple send flow within full screen view should still be usable', async function () { + const initialBalance = roundToXDecimalPlaces( + generateRandNumBetween(10, 100), + 4, + ); + + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + ganacheOptions: generateGanacheOptions({ + accounts: [ + { + secretKey: DEFAULT_GANACHE_OPTIONS.accounts[0].secretKey, + balance: generateETHBalance(initialBalance), + }, + ], + }), + title: this.test.title, + driverOptions: { openDevToolsForTabs: true }, + }, + async ({ driver }) => { + await driver.navigate(); + + await unlockWallet(driver, WALLET_PASSWORD); + + await assertETHBalance(driver, initialBalance); + + // first send ETH and then terminate SW + const RECIPIENT_ADDRESS = '0x985c30949c92df7a0bd42e0f3e3d539ece98db24'; + const amountFirstTx = roundToXDecimalPlaces( + generateRandNumBetween(0.5, 2), + 4, + ); + + const gasFeesFirstTx = await simpleSendETH( + driver, + amountFirstTx, + RECIPIENT_ADDRESS, + ); + const totalAfterFirstTx = roundToXDecimalPlaces( + initialBalance - amountFirstTx - gasFeesFirstTx, + 4, + ); + + await terminateServiceWorker(driver); + + await assertETHBalance(driver, totalAfterFirstTx); + + // first send ETH #2 and then terminate SW + const amountSecondTx = roundToXDecimalPlaces( + generateRandNumBetween(0.5, 2), + 4, + ); + const gasFeesSecondTx = await simpleSendETH( + driver, + amountSecondTx, + RECIPIENT_ADDRESS, + ); + const totalAfterSecondTx = roundToXDecimalPlaces( + initialBalance - + amountFirstTx - + gasFeesFirstTx - + amountSecondTx - + gasFeesSecondTx, + 4, + ); + + await terminateServiceWorker(driver); + + await assertETHBalance(driver, totalAfterSecondTx); + + // first terminate SW and then send ETH + const amountThirdTx = roundToXDecimalPlaces( + generateRandNumBetween(0.5, 2), + 4, + ); + const gasFeesThirdTx = await simpleSendETH( + driver, + amountThirdTx, + RECIPIENT_ADDRESS, + ); + const totalAfterThirdTx = roundToXDecimalPlaces( + initialBalance - + amountFirstTx - + gasFeesFirstTx - + amountSecondTx - + gasFeesSecondTx - + amountThirdTx - + gasFeesThirdTx, + 4, + ); + + await assertETHBalance(driver, totalAfterThirdTx); + }, + ); + + async function simpleSendETH(driver, value, recipient) { + await switchToWindow(driver, WINDOW_TITLES.ExtensionInFullScreenView); + + await driver.clickElement('[data-testid="eth-overview-send"]'); + await driver.fill('[data-testid="ens-input"]', recipient); + const formattedValue = `${value}`.replace('.', ','); + await driver.fill('.unit-input__input', formattedValue); + + await driver.clickElement('[data-testid="page-container-footer-next"]'); + + const gasFeesEl = await driver.findElement( + '.transaction-detail-item__detail-values .currency-display-component', + ); + const gasFees = await gasFeesEl.getText(); + + await driver.clickElement('[data-testid="page-container-footer-next"]'); + await driver.clickElement('[data-testid="home__activity-tab"]'); + await driver.findElement('.transaction-list-item'); + // reset view to assets tab + await driver.clickElement('[data-testid="home__asset-tab"]'); + + return gasFees; + } + + async function assertETHBalance(driver, expectedBalance) { + await switchToWindow(driver, WINDOW_TITLES.ExtensionInFullScreenView); + + const isETHBalanceOverviewPresentAndVisible = + await driver.isElementPresentAndVisible({ + css: '[data-testid="eth-overview__primary-currency"]', + text: `${expectedBalance} ETH`, + }); + + assert.equal( + isETHBalanceOverviewPresentAndVisible, + true, + `Balance DOM element should be visible and match ${expectedBalance} ETH.`, + ); + } + }); + + it('Should continue to support add network dApp interactions after service worker re-starts multiple times', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .build(), + ganacheOptions: generateGanacheOptions({ + concurrent: { port: 8546, chainId: 1338 }, + }), + title: this.test.title, + driverOptions: { openDevToolsForTabs: true }, + }, + async ({ driver }) => { + await driver.navigate(); + + await unlockWallet(driver, WALLET_PASSWORD); + + await openDapp(driver); + + // Click add Ethereum chain + await switchToWindow(driver, WINDOW_TITLES.TestDApp); + await driver.clickElement('#addEthereumChain'); + await driver.waitUntilXWindowHandles(2); + + // Notification pop up opens + await switchToWindow(driver, WINDOW_TITLES.Notification); + let notification = await driver.isElementPresent({ + text: 'Allow this site to add a network?', + tag: 'h3', + }); + assert.ok(notification, 'Dapp action does not appear in Metamask'); + + // Cancel Notification + await driver.clickElement({ text: 'Cancel', tag: 'button' }); + await driver.waitUntilXWindowHandles(2); + + // Terminate Service Worker + await switchToWindow(driver, WINDOW_TITLES.TestDApp); + await terminateServiceWorker(driver); + + // Click add Ethereum chain #2 + await switchToWindow(driver, WINDOW_TITLES.TestDApp); + await driver.clickElement('#addEthereumChain'); + await driver.waitUntilXWindowHandles(2); + + // Notification pop up opens + await switchToWindow(driver, WINDOW_TITLES.Notification); + notification = await driver.isElementPresent({ + text: 'Allow this site to add a network?', + tag: 'h3', + }); + assert.ok(notification, 'Dapp action does not appear in Metamask'); + + // Cancel Notification + await driver.clickElement({ text: 'Cancel', tag: 'button' }); + await driver.waitUntilXWindowHandles(2); + + // Terminate Service Worker + await switchToWindow(driver, WINDOW_TITLES.TestDApp); + await terminateServiceWorker(driver); + + // Click add Ethereum chain #3 + await switchToWindow(driver, WINDOW_TITLES.TestDApp); + await driver.clickElement('#addEthereumChain'); + await driver.waitUntilXWindowHandles(2); + + // Notification pop up opens + await switchToWindow(driver, WINDOW_TITLES.Notification); + notification = await driver.isElementPresent({ + text: 'Allow this site to add a network?', + tag: 'h3', + }); + assert.ok(notification, 'Dapp action does not appear in Metamask'); + + // Accept Notification + await driver.clickElement({ text: 'Approve', tag: 'button' }); + await driver.clickElement({ text: 'Switch network', tag: 'button' }); + await driver.waitUntilXWindowHandles(2); + }, + ); + }); + + it('Should continue to support send ETH dApp interactions after service worker re-starts multiple times', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .build(), + ganacheOptions: generateGanacheOptions({ + concurrent: { port: 8546, chainId: 1338 }, + }), + title: this.test.title, + driverOptions: { openDevToolsForTabs: true }, + }, + async ({ driver }) => { + await driver.navigate(); + + await unlockWallet(driver, WALLET_PASSWORD); + + await openDapp(driver); + + await clickSendButton(driver); + await driver.waitUntilXWindowHandles(2); + + await switchToWindow(driver, WINDOW_TITLES.TestDApp); + await terminateServiceWorker(driver); + await driver.waitUntilXWindowHandles(2); + + await clickSendButton(driver); + await driver.waitUntilXWindowHandles(2); + + await switchToWindow(driver, WINDOW_TITLES.TestDApp); + await terminateServiceWorker(driver); + + await clickSendButton(driver); + await driver.waitUntilXWindowHandles(2); + + await assertNumberOfTransactionsInPopUp(driver, 3); + + await confirmETHSendNotification(driver, 1); + + await assertNumberOfTransactionsInPopUp(driver, 2); + + await confirmETHSendNotification(driver, 1); + + await confirmETHSendNotification(driver, 1); + }, + ); + + async function clickSendButton(driver) { + // Click send button + await switchToWindow(driver, WINDOW_TITLES.TestDApp); + + await driver.waitForSelector({ + css: '#sendButton', + text: 'Send', + }); + await driver.clickElement('#sendButton'); + } + + async function confirmETHSendNotification(driver, amount) { + await switchToWindow(driver, WINDOW_TITLES.Notification); + + await driver.clickElement({ + text: 'Edit', + tag: 'span', + }); + + await driver.fill('[data-testid="currency-input"]', amount); + + await driver.clickElement({ + text: 'Next', + tag: 'button', + }); + + await driver.clickElement({ + text: 'Confirm', + tag: 'button', + }); + } + + async function assertNumberOfTransactionsInPopUp(driver, number) { + await switchToWindow(driver, WINDOW_TITLES.Notification); + const navEl = await driver.findElement( + '.confirm-page-container-navigation__navtext', + ); + + const notificationProgress = await navEl.getText(); + + assert.ok(notificationProgress, `1 of ${number}`); + } + }); + + it('Should lock wallet when a browser session ends (after turning off the extension)', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .build(), + ganacheOptions: generateGanacheOptions({ + concurrent: { port: 8546, chainId: 1338 }, + }), + title: this.test.title, + }, + async ({ driver }) => { + const { extensionUrl } = driver; + const extensionId = extensionUrl.split('//')[1]; + + await driver.navigate(); + + await unlockWallet(driver, WALLET_PASSWORD); + + await reloadExtension(driver, extensionId); + + // ensure extension finishes reloading before reopening full screen extension + await sleepSeconds(0.1); + + await driver.openNewPage(`${extensionUrl}/home.html`); + + const passwordField = await driver.isElementPresent('#password'); + assert.ok( + passwordField, + 'Password screen is not visible. Wallet should have been locked.', + ); + }, + ); + + async function reloadExtension(driver, extensionId) { + await switchToWindow(driver, WINDOW_TITLES.ExtensionInFullScreenView); + + await driver.openNewPage('chrome://extensions/'); + + // extensions-manager + const extensionsManager = await driver.findElement('extensions-manager'); + + // shadowRoot + const extensionsManagerShadowRoot = await driver.executeScript( + 'return arguments[0][0].shadowRoot', + extensionsManager, + ); + + // cr-view-manager + const viewManager = await extensionsManagerShadowRoot.findElement({ + css: '#viewManager', + }); + + // extensions-item-list + const itemList = await viewManager.findElement({ + css: '#items-list', + }); + + // shadowRoot + const itemListShadowRoot = await driver.executeScript( + 'return arguments[0][0].shadowRoot', + itemList, + ); + + // extension-item + const extensionItem = await await itemListShadowRoot.findElement({ + css: `#${extensionId}`, + }); + + // shadowRoot + const extensionItemShadowRoot = await driver.executeScript( + 'return arguments[0][0].shadowRoot', + extensionItem, + ); + + // cr-icon-button + const devReloadButton = await extensionItemShadowRoot.findElement({ + css: '#dev-reload-button', + }); + + // shadowRoot + const devReloadButtonShadowRoot = await driver.executeScript( + 'return arguments[0][0].shadowRoot', + devReloadButton, + ); + + // cr-icon-button + const reloadBtn = await devReloadButtonShadowRoot.findElement({ + css: '#maskedImage', + }); + + await reloadBtn.click(); + } + }); +}); diff --git a/test/e2e/mv3/phishing-warning-sw-restart.spec.js b/test/e2e/mv3/phishing-warning-sw-restart.spec.js index de01139fe..2e10f35b2 100644 --- a/test/e2e/mv3/phishing-warning-sw-restart.spec.js +++ b/test/e2e/mv3/phishing-warning-sw-restart.spec.js @@ -5,9 +5,11 @@ const { openDapp, defaultGanacheOptions, assertAccountBalanceForDOM, - restartServiceWorker, SERVICE_WORKER_URL, regularDelayMs, + WALLET_PASSWORD, + unlockWallet, + terminateServiceWorker, } = require('../helpers'); const FixtureBuilder = require('../fixture-builder'); @@ -28,12 +30,12 @@ describe('Phishing warning page', function () { }, async ({ driver, ganacheServer }) => { await driver.navigate(); - // log in wallet - await driver.fill('#password', 'correct horse battery staple'); - await driver.press('#password', driver.Key.ENTER); + + await unlockWallet(driver, WALLET_PASSWORD); // DAPP is detected as phishing page await openDapp(driver); + const phishingPageHeader = await driver.findElements({ text: 'Deceptive site ahead', tag: 'h1', @@ -42,7 +44,7 @@ describe('Phishing warning page', function () { // Restart service worker await driver.openNewPage(SERVICE_WORKER_URL); - await restartServiceWorker(driver); + await terminateServiceWorker(driver); await driver.delay(regularDelayMs); // wait until extension is reloaded @@ -55,6 +57,7 @@ describe('Phishing warning page', function () { await openDapp(driver); // - extension, dapp, service worker and new dapp await driver.waitUntilXWindowHandles(4); + const newPhishingPageHeader = await driver.findElements({ text: 'Deceptive site ahead', tag: 'h1', diff --git a/test/e2e/tests/eth-sign.spec.js b/test/e2e/tests/eth-sign.spec.js index 4cde44f32..ce8da87bb 100644 --- a/test/e2e/tests/eth-sign.spec.js +++ b/test/e2e/tests/eth-sign.spec.js @@ -3,8 +3,8 @@ const { withFixtures, openDapp, DAPP_URL, - login, defaultGanacheOptions, + unlockWallet, } = require('../helpers'); const FixtureBuilder = require('../fixture-builder'); @@ -21,7 +21,7 @@ describe('Eth sign', function () { }, async ({ driver }) => { await driver.navigate(); - await login(driver); + await unlockWallet(driver); await openDapp(driver); await driver.clickElement('#ethSign'); @@ -56,7 +56,7 @@ describe('Eth sign', function () { }, async ({ driver }) => { await driver.navigate(); - await login(driver); + await unlockWallet(driver); await openDapp(driver); await driver.clickElement('#ethSign'); diff --git a/test/e2e/webdriver/driver.js b/test/e2e/webdriver/driver.js index cebeae935..243337c59 100644 --- a/test/e2e/webdriver/driver.js +++ b/test/e2e/webdriver/driver.js @@ -407,6 +407,11 @@ class Driver { await this.driver.close(); } + async closeWindowHandle(windowHandle) { + await this.driver.switchTo().window(windowHandle); + await this.driver.close(); + } + // Close Alert Popup async closeAlertPopup() { return await this.driver.switchTo().alert().accept(); diff --git a/yarn.lock b/yarn.lock index 56345e62f..04b8eadba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10170,13 +10170,13 @@ __metadata: linkType: hard "axios@npm:^1.2.1": - version: 1.2.2 - resolution: "axios@npm:1.2.2" + version: 1.4.0 + resolution: "axios@npm:1.4.0" dependencies: follow-redirects: ^1.15.0 form-data: ^4.0.0 proxy-from-env: ^1.1.0 - checksum: 6e357491b38426c5720f7328ecbafca3c643b03952c052d787570672ce7a9365717c2d64db4ce97cfbee3f830fa405101e360e14d0857ef7f96a9f4d814c4e03 + checksum: 7fb6a4313bae7f45e89d62c70a800913c303df653f19eafec88e56cea2e3821066b8409bc68be1930ecca80e861c52aa787659df0ffec6ad4d451c7816b9386b languageName: node linkType: hard @@ -12570,9 +12570,9 @@ __metadata: linkType: hard "compare-versions@npm:^5.0.1": - version: 5.0.1 - resolution: "compare-versions@npm:5.0.1" - checksum: 302a4e46224b47b9280cf894c6c87d8df912671fa391dcdbf0e63438d9b0a69fe20dd747fb439e8d54c43af016ff4eaaf0a4c9d8e7ca358bcd12dadf4ad2935e + version: 5.0.3 + resolution: "compare-versions@npm:5.0.3" + checksum: f66a4bb6ef8ff32031cc92c04dea4bbead039e72a7f6c7df7ef05f5a42ddca9202f8875b7449add54181e73b89f039662a8760c8db0ab036c4e8f653a7cd29c1 languageName: node linkType: hard From bce053f7ef4af71a2f09562cbba71ba661b55ffe Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Tue, 20 Jun 2023 09:21:10 -0230 Subject: [PATCH 004/118] Update LavaMoat build policy (#19668) The LavaMoat build policy has been updated to match what is generated on CI. --- lavamoat/build-system/policy.json | 71 ++++--------------------------- 1 file changed, 8 insertions(+), 63 deletions(-) diff --git a/lavamoat/build-system/policy.json b/lavamoat/build-system/policy.json index 89e2820e1..9ee988092 100644 --- a/lavamoat/build-system/policy.json +++ b/lavamoat/build-system/policy.json @@ -980,7 +980,6 @@ "packages": { "@lavamoat/allow-scripts>@npmcli/run-script>node-gyp>npmlog>are-we-there-yet": true, "@lavamoat/allow-scripts>@npmcli/run-script>node-gyp>npmlog>gauge": true, - "@storybook/addon-mdx-gfm>@storybook/node-logger>npmlog>console-control-strings": true, "@storybook/react>@storybook/node-logger>npmlog>console-control-strings": true, "nyc>yargs>set-blocking": true } @@ -1009,9 +1008,6 @@ "@lavamoat/allow-scripts>@npmcli/run-script>node-gyp>npmlog>gauge>aproba": true, "@lavamoat/allow-scripts>@npmcli/run-script>node-gyp>npmlog>gauge>string-width": true, "@lavamoat/allow-scripts>@npmcli/run-script>node-gyp>npmlog>gauge>strip-ansi": true, - "@storybook/addon-mdx-gfm>@storybook/node-logger>npmlog>console-control-strings": true, - "@storybook/addon-mdx-gfm>@storybook/node-logger>npmlog>gauge>has-unicode": true, - "@storybook/addon-mdx-gfm>@storybook/node-logger>npmlog>gauge>wide-align": true, "@storybook/react>@storybook/node-logger>npmlog>console-control-strings": true, "@storybook/react>@storybook/node-logger>npmlog>gauge>has-unicode": true, "@storybook/react>@storybook/node-logger>npmlog>gauge>wide-align": true, @@ -1137,33 +1133,11 @@ "@metamask/jazzicon>color>color-convert>color-name": true } }, - "@sentry/cli>mkdirp": { - "builtin": { - "fs": true, - "path.dirname": true, - "path.resolve": true - } - }, "@storybook/addon-knobs>qs": { "packages": { "string.prototype.matchall>side-channel": true } }, - "@storybook/addon-mdx-gfm>@storybook/node-logger>npmlog>gauge>has-unicode": { - "builtin": { - "os.type": true - }, - "globals": { - "process.env.LANG": true, - "process.env.LC_ALL": true, - "process.env.LC_CTYPE": true - } - }, - "@storybook/addon-mdx-gfm>@storybook/node-logger>npmlog>gauge>wide-align": { - "packages": { - "yargs>string-width": true - } - }, "@storybook/core>@storybook/core-server>x-default-browser>default-browser-id>untildify>os-homedir": { "builtin": { "os.homedir": true @@ -4895,20 +4869,9 @@ }, "packages": { "@storybook/core>@storybook/core-server>x-default-browser>default-browser-id>untildify>os-homedir": true, - "gulp-watch>chokidar>fsevents>node-pre-gyp>nopt>osenv>os-homedir": true, "gulp-watch>chokidar>fsevents>node-pre-gyp>nopt>osenv>os-tmpdir": true } }, - "gulp-watch>chokidar>fsevents>node-pre-gyp>nopt>osenv>os-homedir": { - "builtin": { - "os.homedir": true - }, - "globals": { - "process.env": true, - "process.getuid": true, - "process.platform": true - } - }, "gulp-watch>chokidar>fsevents>node-pre-gyp>nopt>osenv>os-tmpdir": { "globals": { "process.env.SystemRoot": true, @@ -4930,34 +4893,9 @@ "setTimeout": true }, "packages": { - "gulp-watch>chokidar>fsevents>node-pre-gyp>rimraf>glob": true, "nyc>glob": true } }, - "gulp-watch>chokidar>fsevents>node-pre-gyp>rimraf>glob": { - "builtin": { - "assert": true, - "events.EventEmitter": true, - "fs": true, - "path.join": true, - "path.resolve": true, - "util": true - }, - "globals": { - "console.error": true, - "process.cwd": true, - "process.nextTick": true, - "process.platform": true - }, - "packages": { - "eslint>minimatch": true, - "gulp-watch>path-is-absolute": true, - "nyc>glob>fs.realpath": true, - "nyc>glob>inflight": true, - "pump>once": true, - "pumpify>inherits": true - } - }, "gulp-watch>chokidar>fsevents>node-pre-gyp>semver": { "globals": { "console": true, @@ -8246,7 +8184,14 @@ "path.dirname": true }, "packages": { - "@sentry/cli>mkdirp": true + "stylelint>file-entry-cache>flat-cache>write>mkdirp": true + } + }, + "stylelint>file-entry-cache>flat-cache>write>mkdirp": { + "builtin": { + "fs": true, + "path.dirname": true, + "path.resolve": true } }, "stylelint>global-modules": { From 3bbfe87e9e7740c25a39cd45a1a404c5d3ec5321 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 20 Jun 2023 09:29:35 -0300 Subject: [PATCH 005/118] feat(action): github action to automatically add label "release-x.y.z" when PRs get merged (#19061) * feat(action): github action to add release label when PR gets merged * feat(action): make sure the action only runs for PRs merged in main branch * fix(action): update labels default color * fix(action): add check on release label format * fix(action): type function explicitely * feat(action): add possibility to extract next release version number from artifact * fix(action): rename next rc cut number into next semver version * feat(action): add a github action to create release branch * fix(action): default branch is develop * fix(action): specify name of workflow used to create release branch * fix(action): handle case where artifact doesn't exist * fix(action): create branch but not the PR * feat(action): fetch next semver version from release branches name or from package.json * fix(action): remove unused Create Release Branch action * fix(action): release branch format was not correct * feat(action): take tags into account when calculating next version number * feat(action): add the possibility to force next semver version * fix(action): update comments * fix(action): adopt kebak-case instead of snake_case * fix(action): rename PERSONAL_ACCESS_TOKEN into RELEASE_LABEL_TOKEN * fix(action): yarn installation not required * fix(action): yarn install shall be immutable * fix(action): make the script compatible with ShellCheck * fix(script): exit script earlier if condition is met * fix(action): use closingIssuesReferences instead of timeline events * fix(action): add execute permissions to script * fix(action): remove duplicate comment --- ...d-release-label-to-pr-and-linked-issues.ts | 328 ++++++++++++++++++ .github/workflows/add-release-label.yml | 42 +++ development/get-next-semver-version.sh | 27 ++ package.json | 3 +- 4 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/add-release-label-to-pr-and-linked-issues.ts create mode 100644 .github/workflows/add-release-label.yml create mode 100644 development/get-next-semver-version.sh diff --git a/.github/scripts/add-release-label-to-pr-and-linked-issues.ts b/.github/scripts/add-release-label-to-pr-and-linked-issues.ts new file mode 100644 index 000000000..0bf34182e --- /dev/null +++ b/.github/scripts/add-release-label-to-pr-and-linked-issues.ts @@ -0,0 +1,328 @@ +import * as core from '@actions/core'; +import { context, getOctokit } from '@actions/github'; +import { GitHub } from '@actions/github/lib/utils'; + +// A labelable object can be a pull request or an issue +interface Labelable { + id: string; + number: number; + repoOwner: string; + repoName: string; + createdAt: string; +} + +main().catch((error: Error): void => { + console.error(error); + process.exit(1); +}); + +async function main(): Promise { + // "GITHUB_TOKEN" is an automatically generated, repository-specific access token provided by GitHub Actions. + // We can't use "GITHUB_TOKEN" here, as its permissions are scoped to the repository where the action is running. + // "GITHUB_TOKEN" does not have access to other repositories, even when they belong to the same organization. + // As we want to update linked issues which are not necessarily located in the same repository, + // we need to create our own "RELEASE_LABEL_TOKEN" with "repo" permissions. + // Such a token allows to access other repositories of the MetaMask organisation. + const personalAccessToken = process.env.RELEASE_LABEL_TOKEN; + if (!personalAccessToken) { + core.setFailed('RELEASE_LABEL_TOKEN not found'); + process.exit(1); + } + + const nextReleaseVersionNumber = process.env.NEXT_SEMVER_VERSION; + if (!nextReleaseVersionNumber) { + // NEXT_SEMVER_VERSION is automatically deduced as minor version bump on top of the latest version + // found, either in repo's list of branches, or in repo's list of tags or in repo's "package.json" version. + // For edge cases (e.g. major version bumps, etc.), where the value can not be deduced automatically, + // NEXT_SEMVER_VERSION can be defined manually set by defining FORCE_NEXT_SEMVER_VERSION variable in + // section "Secrets and variables">"Actions">"Variables">"New repository variable" in the settings of this repo. + // Example value: 6.5.0 + core.setFailed('NEXT_SEMVER_VERSION not found'); + process.exit(1); + } + + if (!isValidVersionFormat(nextReleaseVersionNumber)) { + core.setFailed(`NEXT_SEMVER_VERSION (${nextReleaseVersionNumber}) is not a valid version format. The expected format is "x.y.z", where "x", "y" and "z" are numbers.`); + process.exit(1); + } + + // Release label indicates the next release version number + // Example release label: "release-6.5.0" + const releaseLabelName = `release-${nextReleaseVersionNumber}`; + const releaseLabelColor = "ededed"; + const releaseLabelDescription = `Issue or pull request that will be included in release ${nextReleaseVersionNumber}`; + + // Initialise octokit, required to call Github GraphQL API + const octokit: InstanceType = getOctokit( + personalAccessToken, + { + previews: ["bane"], // The "bane" preview is required for adding, updating, creating and deleting labels. + }, + ); + + // Retrieve pull request info from context + const prRepoOwner = context.repo.owner; + const prRepoName = context.repo.repo; + const prNumber = context.payload.pull_request?.number; + if (!prNumber) { + core.setFailed('Pull request number not found'); + process.exit(1); + } + + // Retrieve pull request + const pullRequest: Labelable = await retrievePullRequest(octokit, prRepoOwner, prRepoName, prNumber); + + // Add the release label to the pull request + await addLabelToLabelable(octokit, pullRequest, releaseLabelName, releaseLabelColor, releaseLabelDescription); + + // Retrieve linked issues for the pull request + const linkedIssues: Labelable[] = await retrieveLinkedIssues(octokit, prRepoOwner, prRepoName, prNumber); + + // Add the release label to the linked issues + for (const linkedIssue of linkedIssues) { + await addLabelToLabelable(octokit, linkedIssue, releaseLabelName, releaseLabelColor, releaseLabelDescription); + } +} + +// This helper function checks if version has the correct format: "x.y.z" where "x", "y" and "z" are numbers. +function isValidVersionFormat(str: string): boolean { + const regex = /^\d+\.\d+\.\d+$/; + return regex.test(str); +} + +// This function retrieves the repo +async function retrieveRepo(octokit: InstanceType, repoOwner: string, repoName: string): Promise { + + const retrieveRepoQuery = ` + query RetrieveRepo($repoOwner: String!, $repoName: String!) { + repository(owner: $repoOwner, name: $repoName) { + id + } + } +`; + + const retrieveRepoResult: { + repository: { + id: string; + }; + } = await octokit.graphql(retrieveRepoQuery, { + repoOwner, + repoName, + }); + + const repoId = retrieveRepoResult?.repository?.id; + + return repoId; +} + +// This function retrieves the label on a specific repo +async function retrieveLabel(octokit: InstanceType, repoOwner: string, repoName: string, labelName: string): Promise { + + const retrieveLabelQuery = ` + query RetrieveLabel($repoOwner: String!, $repoName: String!, $labelName: String!) { + repository(owner: $repoOwner, name: $repoName) { + label(name: $labelName) { + id + } + } + } + `; + + const retrieveLabelResult: { + repository: { + label: { + id: string; + }; + }; + } = await octokit.graphql(retrieveLabelQuery, { + repoOwner, + repoName, + labelName, + }); + + const labelId = retrieveLabelResult?.repository?.label?.id; + + return labelId; +} + +// This function creates the label on a specific repo +async function createLabel(octokit: InstanceType, repoId: string, labelName: string, labelColor: string, labelDescription: string): Promise { + + const createLabelMutation = ` + mutation CreateLabel($repoId: ID!, $labelName: String!, $labelColor: String!, $labelDescription: String) { + createLabel(input: {repositoryId: $repoId, name: $labelName, color: $labelColor, description: $labelDescription}) { + label { + id + } + } + } + `; + + const createLabelResult: { + createLabel: { + label: { + id: string; + }; + }; + } = await octokit.graphql(createLabelMutation, { + repoId, + labelName, + labelColor, + labelDescription, + }); + + const labelId = createLabelResult?.createLabel?.label?.id; + + return labelId; +} + +// This function creates or retrieves the label on a specific repo +async function createOrRetrieveLabel(octokit: InstanceType, repoOwner: string, repoName: string, labelName: string, labelColor: string, labelDescription: string): Promise { + + // Check if label already exists on the repo + let labelId = await retrieveLabel(octokit, repoOwner, repoName, labelName); + + // If label doesn't exist on the repo, create it + if (!labelId) { + // Retrieve PR's repo + const repoId = await retrieveRepo(octokit, repoOwner, repoName); + + // Create label on repo + labelId = await createLabel(octokit, repoId, labelName, labelColor, labelDescription); + } + + return labelId; +} + +// This function retrieves the pull request on a specific repo +async function retrievePullRequest(octokit: InstanceType, repoOwner: string, repoName: string, prNumber: number): Promise { + + const retrievePullRequestQuery = ` + query GetPullRequest($repoOwner: String!, $repoName: String!, $prNumber: Int!) { + repository(owner: $repoOwner, name: $repoName) { + pullRequest(number: $prNumber) { + id + createdAt + } + } + } + `; + + const retrievePullRequestResult: { + repository: { + pullRequest: { + id: string; + createdAt: string; + }; + }; + } = await octokit.graphql(retrievePullRequestQuery, { + repoOwner, + repoName, + prNumber, + }); + + const pullRequest: Labelable = { + id: retrievePullRequestResult?.repository?.pullRequest?.id, + number: prNumber, + repoOwner: repoOwner, + repoName: repoName, + createdAt: retrievePullRequestResult?.repository?.pullRequest?.createdAt, + } + + return pullRequest; +} + + +// This function retrieves the list of linked issues for a pull request +async function retrieveLinkedIssues(octokit: InstanceType, repoOwner: string, repoName: string, prNumber: number): Promise { + + // We assume there won't be more than 100 linked issues + const retrieveLinkedIssuesQuery = ` + query ($repoOwner: String!, $repoName: String!, $prNumber: Int!) { + repository(owner: $repoOwner, name: $repoName) { + pullRequest(number: $prNumber) { + closingIssuesReferences(first: 100) { + nodes { + id + number + createdAt + repository { + name + owner { + login + } + } + } + } + } + } + } + `; + + const retrieveLinkedIssuesResult: { + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: Array<{ + id: string; + number: number; + createdAt: string; + repository: { + name: string; + owner: { + login: string; + }; + }; + }>; + }; + }; + }; + } = await octokit.graphql(retrieveLinkedIssuesQuery, { + repoOwner, + repoName, + prNumber + }); + + const linkedIssues = retrieveLinkedIssuesResult?.repository?.pullRequest?.closingIssuesReferences?.nodes?.map((issue: { + id: string; + number: number; + createdAt: string; + repository: { + name: string; + owner: { + login: string; + }; + }; + }) => { + return { + id: issue?.id, + number: issue?.number, + repoOwner: issue?.repository?.owner?.login, + repoName: issue?.repository?.name, + createdAt: issue?.createdAt + }; + }) || []; + + return linkedIssues; +} + +// This function adds label to a labelable object (i.e. a pull request or an issue) +async function addLabelToLabelable(octokit: InstanceType, labelable: Labelable, labelName: string, labelColor: string, labelDescription: string): Promise { + + // Retrieve label from the labelable's repo, or create label if required + const labelId = await createOrRetrieveLabel(octokit, labelable?.repoOwner, labelable?.repoName, labelName, labelColor, labelDescription); + + const addLabelsToLabelableMutation = ` + mutation AddLabelsToLabelable($labelableId: ID!, $labelIds: [ID!]!) { + addLabelsToLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) { + clientMutationId + } + } + `; + + await octokit.graphql(addLabelsToLabelableMutation, { + labelableId: labelable?.id, + labelIds: [labelId], + }); + +} diff --git a/.github/workflows/add-release-label.yml b/.github/workflows/add-release-label.yml new file mode 100644 index 000000000..00f0293a6 --- /dev/null +++ b/.github/workflows/add-release-label.yml @@ -0,0 +1,42 @@ +name: Add release label to PR and linked issues when PR gets merged + +on: + pull_request: + branches: + - develop + types: + - closed + +jobs: + add-release-label: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 # This is needed to checkout all branches + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '16' + + - name: Install dependencies + run: yarn --immutable + + - name: Set execute permissions for script + run: chmod +x ./development/get-next-semver-version.sh + + - name: Get the next semver version + id: get-next-semver-version + env: + FORCE_NEXT_SEMVER_VERSION: ${{ vars.FORCE_NEXT_SEMVER_VERSION }} + run: ./development/get-next-semver-version.sh $FORCE_NEXT_SEMVER_VERSION + + - name: Add release label to PR and linked issues + id: add-release-label-to-pr-and-linked-issues + env: + RELEASE_LABEL_TOKEN: ${{ secrets.RELEASE_LABEL_TOKEN }} + NEXT_SEMVER_VERSION: ${{ env.NEXT_SEMVER_VERSION }} + run: npm run add-release-label-to-pr-and-linked-issues diff --git a/development/get-next-semver-version.sh b/development/get-next-semver-version.sh new file mode 100644 index 000000000..810747197 --- /dev/null +++ b/development/get-next-semver-version.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +FORCE_NEXT_SEMVER_VERSION=$1 + +# If FORCE_NEXT_SEMVER_VERSION is defined and not empty, use its value and skip the next operations +if [ -n "$FORCE_NEXT_SEMVER_VERSION" ] +then + echo "NEXT_SEMVER_VERSION=${FORCE_NEXT_SEMVER_VERSION}" >> "$GITHUB_ENV" + exit 0 +fi + +# Get the highest version from release branches +VERSION_BRANCHES=$(git branch -r | grep -o 'release/[0-9]*\.[0-9]*\.[0-9]*' | grep -o '[0-9]*\.[0-9]*\.[0-9]*' | sort --version-sort | tail -n 1) + +# Get the highest version from tags +VERSION_TAGS=$(git tag | grep -o 'v[0-9]*\.[0-9]*\.[0-9]*' | grep -o '[0-9]*\.[0-9]*\.[0-9]*' | sort --version-sort | tail -n 1) + +# Get the version from package.json +VERSION_PACKAGE=$(node -p "require('./package.json').version") + +# Compare versions and keep the highest one +HIGHEST_VERSION=$(printf "%s\n%s\n%s" "$VERSION_BRANCHES" "$VERSION_TAGS" "$VERSION_PACKAGE" | sort --version-sort | tail -n 1) + +# Increment the minor version of the highest version found +NEXT_VERSION=$(echo "$HIGHEST_VERSION" | awk -F. -v OFS=. '{$2++; print}') + +echo "NEXT_SEMVER_VERSION=${NEXT_VERSION}" >> "$GITHUB_ENV" diff --git a/package.json b/package.json index 43e66d32a..62e63668e 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,8 @@ "fitness-functions": "ts-node development/fitness-functions/index.ts", "generate-beta-commit": "node ./development/generate-beta-commit.js", "validate-branch-name": "validate-branch-name", - "label-prs": "ts-node ./.github/scripts/label-prs.ts" + "label-prs": "ts-node ./.github/scripts/label-prs.ts", + "add-release-label-to-pr-and-linked-issues": "ts-node ./.github/scripts/add-release-label-to-pr-and-linked-issues.ts" }, "resolutions": { "@babel/core": "patch:@babel/core@npm%3A7.21.5#./.yarn/patches/@babel-core-npm-7.21.5-c72c337956.patch", From b247f272ba643f002c66962c18b08a65d92a49d0 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 20 Jun 2023 13:44:11 +0100 Subject: [PATCH 006/118] Support translation in background code (#19650) --- .storybook/i18n.js | 2 +- app/_locales/en/messages.json | 24 ++ app/scripts/metamask-controller.js | 5 + app/scripts/platforms/extension.js | 27 +- app/scripts/translate.test.ts | 125 +++++++ app/scripts/translate.ts | 31 ++ development/verify-locale-strings.js | 1 + jest.config.js | 1 + shared/lib/error-utils.js | 5 +- shared/lib/error-utils.test.js | 4 +- shared/modules/i18n.test.ts | 335 ++++++++++++++++++ shared/modules/i18n.ts | 223 ++++++++++++ tsconfig.json | 3 +- ui/contexts/i18n.js | 2 +- .../__snapshots__/i18n-helper.test.js.snap | 52 +-- ui/helpers/utils/i18n-helper.test.js | 212 ++++------- ui/helpers/utils/i18n-helper.tsx | 175 ++------- ui/hooks/useTransactionDisplayData.test.js | 2 +- ui/index.test.js | 2 +- ui/store/actions.ts | 2 +- 20 files changed, 872 insertions(+), 361 deletions(-) create mode 100644 app/scripts/translate.test.ts create mode 100644 app/scripts/translate.ts create mode 100644 shared/modules/i18n.test.ts create mode 100644 shared/modules/i18n.ts diff --git a/.storybook/i18n.js b/.storybook/i18n.js index 314ea544e..d6c15f07c 100644 --- a/.storybook/i18n.js +++ b/.storybook/i18n.js @@ -1,6 +1,6 @@ import React, { Component, createContext, useMemo } from 'react'; import PropTypes from 'prop-types'; -import { getMessage } from '../ui/helpers/utils/i18n-helper'; +import { getMessage } from '../shared/modules/i18n'; import { I18nContext } from '../ui/contexts/i18n'; export { I18nContext }; diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index d3c554cc1..023c14ace 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2559,6 +2559,30 @@ "notePlaceholder": { "message": "The approver will see this note when approving the transaction at the custodian." }, + "notificationTransactionFailedMessage": { + "message": "Transaction $1 failed! $2", + "description": "Content of the browser notification that appears when a transaction fails" + }, + "notificationTransactionFailedMessageMMI": { + "message": "Transaction failed! $1", + "description": "Content of the browser notification that appears when a transaction fails in MMI" + }, + "notificationTransactionFailedTitle": { + "message": "Failed transaction", + "description": "Title of the browser notification that appears when a transaction fails" + }, + "notificationTransactionSuccessMessage": { + "message": "Transaction $1 confirmed!", + "description": "Content of the browser notification that appears when a transaction is confirmed" + }, + "notificationTransactionSuccessTitle": { + "message": "Confirmed transaction", + "description": "Title of the browser notification that appears when a transaction is confirmed" + }, + "notificationTransactionSuccessView": { + "message": "View on $1", + "description": "Additional content in browser notification that appears when a transaction is confirmed and has a block explorer URL" + }, "notifications": { "message": "Notifications" }, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 7b6b049d1..bb6423a28 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -205,6 +205,7 @@ import { } from './controllers/permissions'; import createRPCMethodTrackingMiddleware from './lib/createRPCMethodTrackingMiddleware'; import { securityProviderCheck } from './lib/security-provider-helpers'; +import { updateCurrentLocale } from './translate'; export const METAMASK_CONTROLLER_EVENTS = { // Fired after state changes that impact the extension badge (unapproved msg count) @@ -360,6 +361,10 @@ export default class MetamaskController extends EventEmitter { ///: END:ONLY_INCLUDE_IN }); + this.preferencesController.store.subscribe(async ({ currentLocale }) => { + await updateCurrentLocale(currentLocale); + }); + this.tokensController = new TokensController({ chainId: this.networkController.store.getState().providerConfig.chainId, onPreferencesStateChange: this.preferencesController.store.subscribe.bind( diff --git a/app/scripts/platforms/extension.js b/app/scripts/platforms/extension.js index c4d64af8f..55f5a29e9 100644 --- a/app/scripts/platforms/extension.js +++ b/app/scripts/platforms/extension.js @@ -6,6 +6,7 @@ import { getEnvironmentType } from '../lib/util'; import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app'; import { TransactionStatus } from '../../../shared/constants/transaction'; import { getURLHostName } from '../../../ui/helpers/utils/util'; +import { t } from '../translate'; export default class ExtensionPlatform { // @@ -181,22 +182,30 @@ export default class ExtensionPlatform { toLower(getURLHostName(url).replace(/([.]\w+)$/u, '')), ); - const title = 'Confirmed transaction'; - const message = `Transaction ${nonce} confirmed! ${ - url.length ? `View on ${view}` : '' - }`; + const title = t('notificationTransactionSuccessTitle'); + let message = t('notificationTransactionSuccessMessage', nonce); + + if (url.length) { + message += ` ${t('notificationTransactionSuccessView', view)}`; + } + await this._showNotification(title, message, url); } async _showFailedTransaction(txMeta, errorMessage) { const nonce = parseInt(txMeta.txParams.nonce, 16); - const title = 'Failed transaction'; - let message = `Transaction ${nonce} failed! ${ - errorMessage || txMeta.err.message - }`; + const title = t('notificationTransactionFailedTitle'); + let message = t( + 'notificationTransactionFailedMessage', + nonce, + errorMessage || txMeta.err.message, + ); ///: BEGIN:ONLY_INCLUDE_IN(build-mmi) if (isNaN(nonce)) { - message = `Transaction failed! ${errorMessage || txMeta.err.message}`; + message = t( + 'notificationTransactionFailedMessageMMI', + errorMessage || txMeta.err.message, + ); } ///: END:ONLY_INCLUDE_IN await this._showNotification(title, message); diff --git a/app/scripts/translate.test.ts b/app/scripts/translate.test.ts new file mode 100644 index 000000000..5be2694df --- /dev/null +++ b/app/scripts/translate.test.ts @@ -0,0 +1,125 @@ +import { + getMessage, + fetchLocale, + FALLBACK_LOCALE, +} from '../../shared/modules/i18n'; +import { t, updateCurrentLocale } from './translate'; + +const localeCodeMock = 'te'; +const keyMock = 'testKey'; +const substitutionsMock = ['a1', 'b2']; +const messageMock = 'testMessage'; +const messageMock2 = 'testMessage2'; +const alternateLocaleDataMock = { [keyMock]: { message: messageMock2 } }; + +jest.mock('../../shared/modules/i18n'); +jest.mock('../_locales/en/messages.json', () => ({ + [keyMock]: { message: messageMock }, +})); + +describe('Translate', () => { + const getMessageMock = getMessage as jest.MockedFunction; + const fetchLocaleMock = fetchLocale as jest.MockedFunction< + typeof fetchLocale + >; + + beforeEach(async () => { + jest.resetAllMocks(); + await updateCurrentLocale(FALLBACK_LOCALE); + }); + + describe('updateCurrentLocale', () => { + it('retrieves locale data from shared module', async () => { + await updateCurrentLocale(localeCodeMock); + + expect(fetchLocale).toHaveBeenCalledTimes(1); + expect(fetchLocale).toHaveBeenCalledWith(localeCodeMock); + }); + + it('does not retrieve locale data if same locale already set', async () => { + await updateCurrentLocale(localeCodeMock); + await updateCurrentLocale(localeCodeMock); + + expect(fetchLocale).toHaveBeenCalledTimes(1); + expect(fetchLocale).toHaveBeenCalledWith(localeCodeMock); + }); + + it('does not retrieve locale data if fallback locale set', async () => { + await updateCurrentLocale(localeCodeMock); + await updateCurrentLocale(FALLBACK_LOCALE); + + expect(fetchLocale).toHaveBeenCalledTimes(1); + expect(fetchLocale).toHaveBeenCalledWith(localeCodeMock); + }); + }); + + describe('t', () => { + it('returns value from shared module', () => { + getMessageMock.mockReturnValue(messageMock); + + expect(t(keyMock, ...substitutionsMock)).toStrictEqual(messageMock); + }); + + it('uses en locale by default', () => { + getMessageMock.mockReturnValue(messageMock); + + t(keyMock, ...substitutionsMock); + + expect(getMessage).toHaveBeenCalledTimes(1); + expect(getMessage).toHaveBeenCalledWith( + FALLBACK_LOCALE, + { [keyMock]: { message: messageMock } }, + keyMock, + substitutionsMock, + ); + }); + + it('uses locale passed to updateCurrentLocale if called', async () => { + (getMessage as jest.MockedFunction).mockReturnValue( + messageMock, + ); + + fetchLocaleMock.mockResolvedValueOnce(alternateLocaleDataMock); + await updateCurrentLocale(localeCodeMock); + + t(keyMock, ...substitutionsMock); + + expect(getMessage).toHaveBeenCalledTimes(1); + expect(getMessage).toHaveBeenCalledWith( + localeCodeMock, + alternateLocaleDataMock, + keyMock, + substitutionsMock, + ); + }); + + it('returns value from en locale as fallback if current locale returns null', async () => { + ( + getMessage as jest.MockedFunction + ).mockReturnValueOnce(null); + + ( + getMessage as jest.MockedFunction + ).mockReturnValueOnce(messageMock2); + + fetchLocaleMock.mockResolvedValueOnce(alternateLocaleDataMock); + await updateCurrentLocale(localeCodeMock); + + expect(t(keyMock, ...substitutionsMock)).toStrictEqual(messageMock2); + + expect(getMessage).toHaveBeenCalledTimes(2); + expect(getMessage).toHaveBeenCalledWith( + FALLBACK_LOCALE, + { [keyMock]: { message: messageMock } }, + keyMock, + substitutionsMock, + ); + expect(getMessage).toHaveBeenCalledWith( + localeCodeMock, + alternateLocaleDataMock, + keyMock, + substitutionsMock, + ); + }); + }); +}); diff --git a/app/scripts/translate.ts b/app/scripts/translate.ts new file mode 100644 index 000000000..583c30ef5 --- /dev/null +++ b/app/scripts/translate.ts @@ -0,0 +1,31 @@ +import enTranslations from '../_locales/en/messages.json'; +import { + FALLBACK_LOCALE, + I18NMessageDict, + fetchLocale, + getMessage, +} from '../../shared/modules/i18n'; + +let currentLocale: string = FALLBACK_LOCALE; +let translations: I18NMessageDict = enTranslations; + +export async function updateCurrentLocale(locale: string): Promise { + if (currentLocale === locale) { + return; + } + + if (locale === FALLBACK_LOCALE) { + translations = enTranslations; + } else { + translations = await fetchLocale(locale); + } + + currentLocale = locale; +} + +export function t(key: string, ...substitutions: string[]): string | null { + return ( + getMessage(currentLocale, translations, key, substitutions) || + getMessage(FALLBACK_LOCALE, enTranslations, key, substitutions) + ); +} diff --git a/development/verify-locale-strings.js b/development/verify-locale-strings.js index c6942bedc..40649cceb 100755 --- a/development/verify-locale-strings.js +++ b/development/verify-locale-strings.js @@ -187,6 +187,7 @@ async function verifyEnglishLocale() { 'shared/**/*.ts', 'app/scripts/constants/**/*.js', 'app/scripts/constants/**/*.ts', + 'app/scripts/platforms/**/*.js', ], { ignore: [...globsToStrictSearch, testGlob], diff --git a/jest.config.js b/jest.config.js index db7b93bcd..6665b9802 100644 --- a/jest.config.js +++ b/jest.config.js @@ -51,6 +51,7 @@ module.exports = { '/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js', '/app/scripts/migrations/*.test.(js|ts)', '/app/scripts/platforms/*.test.js', + '/app/scripts/translate.test.ts', '/shared/**/*.test.(js|ts)', '/ui/**/*.test.(js|ts|tsx)', '/development/fitness-functions/**/*.test.(js|ts|tsx)', diff --git a/shared/lib/error-utils.js b/shared/lib/error-utils.js index d7a7d284d..731fe93b4 100644 --- a/shared/lib/error-utils.js +++ b/shared/lib/error-utils.js @@ -3,10 +3,7 @@ import browser from 'webextension-polyfill'; ///: END:ONLY_INCLUDE_IN import { memoize } from 'lodash'; import getFirstPreferredLangCode from '../../app/scripts/lib/get-first-preferred-lang-code'; -import { - fetchLocale, - loadRelativeTimeFormatLocaleData, -} from '../../ui/helpers/utils/i18n-helper'; +import { fetchLocale, loadRelativeTimeFormatLocaleData } from '../modules/i18n'; ///: BEGIN:ONLY_INCLUDE_IN(desktop) import { renderDesktopError } from '../../ui/pages/desktop-error/render-desktop-error'; import { EXTENSION_ERROR_PAGE_TYPES } from '../constants/desktop'; diff --git a/shared/lib/error-utils.test.js b/shared/lib/error-utils.test.js index f761acd10..9874d9b24 100644 --- a/shared/lib/error-utils.test.js +++ b/shared/lib/error-utils.test.js @@ -1,5 +1,5 @@ import browser from 'webextension-polyfill'; -import { fetchLocale } from '../../ui/helpers/utils/i18n-helper'; +import { fetchLocale } from '../modules/i18n'; import { SUPPORT_LINK } from './ui-utils'; import { downloadDesktopApp, @@ -12,7 +12,7 @@ import { } from './error-utils'; import { openCustomProtocol } from './deep-linking'; -jest.mock('../../ui/helpers/utils/i18n-helper', () => ({ +jest.mock('../modules/i18n', () => ({ fetchLocale: jest.fn(), loadRelativeTimeFormatLocaleData: jest.fn(), })); diff --git a/shared/modules/i18n.test.ts b/shared/modules/i18n.test.ts new file mode 100644 index 000000000..3353e4732 --- /dev/null +++ b/shared/modules/i18n.test.ts @@ -0,0 +1,335 @@ +import log from 'loglevel'; +import { + FALLBACK_LOCALE, + I18NMessageDict, + clearCaches, + fetchLocale, + getMessage, + loadRelativeTimeFormatLocaleData, +} from './i18n'; + +const localeCodeMock = 'te'; +const keyMock = 'testKey'; +const errorLocaleMock = 'testLocaleError'; +const errorMock = 'TestError'; + +jest.mock('loglevel'); + +jest.mock('./fetch-with-timeout', () => + jest.fn(() => (url: string) => { + return Promise.resolve({ + json: () => { + if (url.includes(errorLocaleMock)) { + throw new Error(errorMock); + } + + return { url }; + }, + }); + }), +); + +describe('I18N Module', () => { + beforeEach(() => { + jest.resetAllMocks(); + clearCaches(); + process.env.IN_TEST = 'true'; + }); + + describe('getMessage', () => { + describe('on error', () => { + it('returns null if no messages', () => { + expect( + getMessage( + localeCodeMock, + null as unknown as I18NMessageDict, + keyMock, + ), + ).toBeNull(); + }); + + describe('if missing key', () => { + describe('if not using fallback locale', () => { + it('logs warning', () => { + expect( + getMessage( + localeCodeMock, + {} as unknown as I18NMessageDict, + keyMock, + ), + ).toBeNull(); + + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith( + `Translator - Unable to find value of key "${keyMock}" for locale "${localeCodeMock}"`, + ); + }); + + it('does not log warning if warning already created', () => { + expect( + getMessage( + localeCodeMock, + {} as unknown as I18NMessageDict, + keyMock, + ), + ).toBeNull(); + + expect( + getMessage( + localeCodeMock, + {} as unknown as I18NMessageDict, + keyMock, + ), + ).toBeNull(); + + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith( + `Translator - Unable to find value of key "${keyMock}" for locale "${localeCodeMock}"`, + ); + }); + }); + + describe('if using fallback locale', () => { + it('logs error', () => { + delete process.env.IN_TEST; + + expect( + getMessage( + FALLBACK_LOCALE, + {} as unknown as I18NMessageDict, + keyMock, + ), + ).toBeNull(); + + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( + new Error( + `Unable to find value of key "${keyMock}" for locale "${FALLBACK_LOCALE}"`, + ), + ); + }); + + it('throws if test env set', () => { + expect(() => + getMessage( + FALLBACK_LOCALE, + {} as unknown as I18NMessageDict, + keyMock, + ), + ).toThrow( + `Unable to find value of key "${keyMock}" for locale "${FALLBACK_LOCALE}"`, + ); + }); + + it('calls onError callback', () => { + const onErrorMock = jest.fn(); + + try { + getMessage( + FALLBACK_LOCALE, + {} as unknown as I18NMessageDict, + keyMock, + [], + onErrorMock, + ); + } catch { + // Expected + } + + expect(onErrorMock).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledWith( + new Error( + `Unable to find value of key "${keyMock}" for locale "${FALLBACK_LOCALE}"`, + ), + ); + }); + + it('does nothing if error already created', () => { + const onErrorMock = jest.fn(); + + try { + getMessage( + FALLBACK_LOCALE, + {} as unknown as I18NMessageDict, + keyMock, + [], + onErrorMock, + ); + } catch { + // Expected + } + + getMessage( + FALLBACK_LOCALE, + {} as unknown as I18NMessageDict, + keyMock, + [], + onErrorMock, + ); + + expect(log.error).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('if missing substitution', () => { + it('logs error', () => { + expect( + getMessage( + localeCodeMock, + { [keyMock]: { message: 'test1 $1 test2 $2 test3' } }, + keyMock, + ['a1'], + ), + ).toStrictEqual('test1 a1 test2 test3'); + + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( + new Error( + `Insufficient number of substitutions for key "${keyMock}" with locale "${localeCodeMock}"`, + ), + ); + }); + + it('calls onError callback', () => { + const onErrorMock = jest.fn(); + + expect( + getMessage( + localeCodeMock, + { [keyMock]: { message: 'test1 $1 test2 $2 test3' } }, + keyMock, + ['a1'], + onErrorMock, + ), + ).toStrictEqual('test1 a1 test2 test3'); + + expect(onErrorMock).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledWith( + new Error( + `Insufficient number of substitutions for key "${keyMock}" with locale "${localeCodeMock}"`, + ), + ); + }); + + it('does nothing if error already created', () => { + const onErrorMock = jest.fn(); + + expect( + getMessage( + localeCodeMock, + { [keyMock]: { message: 'test1 $1 test2 $2 test3' } }, + keyMock, + ['a1'], + onErrorMock, + ), + ).toStrictEqual('test1 a1 test2 test3'); + + expect( + getMessage( + localeCodeMock, + { [keyMock]: { message: 'test1 $1 test2 $2 test3' } }, + keyMock, + ['a1'], + onErrorMock, + ), + ).toStrictEqual('test1 a1 test2 test3'); + + expect(log.error).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + it('returns text only if no substitutions', () => { + expect( + getMessage( + localeCodeMock, + { [keyMock]: { message: 'testValue' } }, + keyMock, + ), + ).toStrictEqual('testValue'); + }); + + it('returns text including substitutions', () => { + expect( + getMessage( + localeCodeMock, + { [keyMock]: { message: 'test1 $1 test2 $2 test3' } }, + keyMock, + ['a1', 'b2'], + ), + ).toStrictEqual('test1 a1 test2 b2 test3'); + }); + + it('returns text including substitutions using custom join', () => { + expect( + getMessage( + localeCodeMock, + { [keyMock]: { message: 'test1 $1 test2 $2 test3' } }, + keyMock, + ['a1', 'b2'], + undefined, + (substitutions) => substitutions.join(','), + ), + ).toStrictEqual('test1 ,a1, test2 ,b2, test3'); + }); + }); + + describe('fetchLocale', () => { + it('returns json from locale file', async () => { + const result = await fetchLocale(localeCodeMock); + expect(result).toStrictEqual({ + url: `./_locales/${localeCodeMock}/messages.json`, + }); + }); + + it('logs if fetch fails', async () => { + await fetchLocale(errorLocaleMock); + + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( + `failed to fetch testLocaleError locale because of Error: ${errorMock}`, + ); + }); + + it('returns empty object if fetch fails', async () => { + expect(await fetchLocale(errorLocaleMock)).toStrictEqual({}); + }); + }); + + describe('loadRelativeTimeFormatLocaleData', () => { + it('adds locale data if function exists', async () => { + const addMock = jest.fn(); + + global.Intl = { + RelativeTimeFormat: { + __addLocaleData: addMock, + }, + } as any; + + await loadRelativeTimeFormatLocaleData(`${localeCodeMock}_test`); + + expect(addMock).toHaveBeenCalledTimes(1); + expect(addMock).toHaveBeenCalledWith({ + url: `./intl/${localeCodeMock}/relative-time-format-data.json`, + }); + }); + + it('does not add locale data if language tag already processed', async () => { + const addMock = jest.fn(); + + global.Intl = { + RelativeTimeFormat: { + __addLocaleData: addMock, + }, + } as any; + + await loadRelativeTimeFormatLocaleData(`${localeCodeMock}_test`); + await loadRelativeTimeFormatLocaleData(`${localeCodeMock}_test`); + + expect(addMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/shared/modules/i18n.ts b/shared/modules/i18n.ts new file mode 100644 index 000000000..601343704 --- /dev/null +++ b/shared/modules/i18n.ts @@ -0,0 +1,223 @@ +import log from 'loglevel'; +import { Json } from '@metamask/utils'; +import getFetchWithTimeout from './fetch-with-timeout'; + +const fetchWithTimeout = getFetchWithTimeout(); + +// From app/_locales folders there is a messages.json file such as app/_locales/en, comes with key and translated results +// and we use as t('reject') to get the translated message in the codebase +// and in i18n lib, the translated message is an object (I18NMessage) with message & description - +// message is the string that will replace the translationKey, and that message may contain replacement variables such as $1, $2, etc. +// Description is key describing the usage of the message. +export interface I18NMessage { + message: string; + description?: string; +} + +// The overall translation file is made of same entries +// translationKey (string) and the I18NMessage as the value. +export interface I18NMessageDict { + [translationKey: string]: I18NMessage; +} + +export type I18NSubstitution = string | (() => any) | object; + +// A parameterized type (or generic type) of maps that use the same structure (translationKey) key +interface I18NMessageDictMap { + [translationKey: string]: R; +} + +export const FALLBACK_LOCALE = 'en'; + +const warned: { [localeCode: string]: I18NMessageDictMap } = {}; + +const missingMessageErrors: I18NMessageDictMap = {}; + +const missingSubstitutionErrors: { + [localeCode: string]: I18NMessageDictMap; +} = {}; + +const relativeTimeFormatLocaleData = new Set(); + +/** + * Returns a localized message for the given key + * + * @param localeCode - The code for the current locale + * @param localeMessages - The map of messages for the current locale + * @param key - The message key + * @param substitutions - A list of message substitution replacements can replace $n in given message + * @param onError - An optional callback to provide additional processing on any errors + * @param join - An optional callback to join the substituted parts using custom logic + * @returns The localized message + */ +export const getMessage = ( + localeCode: string, + localeMessages: I18NMessageDict, + key: string, + substitutions?: I18NSubstitution[], + onError?: (error: Error) => void, + join?: (substitutedParts: I18NSubstitution[]) => T, +): T | string | null => { + if (!localeMessages) { + return null; + } + + const message = localeMessages[key]; + + if (!message) { + missingKeyError(key, localeCode, onError); + return null; + } + + const text = message.message; + + const parts = hasSubstitutions(substitutions) + ? applySubstitutions( + text, + substitutions as I18NSubstitution[], + key, + localeCode, + onError, + ) + : [text]; + + return join ? join(parts) : parts.join(''); +}; + +export async function fetchLocale( + localeCode: string, +): Promise { + try { + const response = await fetchWithTimeout( + `./_locales/${localeCode}/messages.json`, + ); + return await response.json(); + } catch (error) { + log.error(`failed to fetch ${localeCode} locale because of ${error}`); + return {}; + } +} + +export async function loadRelativeTimeFormatLocaleData( + localeCode: string, +): Promise { + const languageTag = localeCode.split('_')[0]; + if ( + Intl.RelativeTimeFormat && + typeof (Intl.RelativeTimeFormat as any).__addLocaleData === 'function' && + !relativeTimeFormatLocaleData.has(languageTag) + ) { + const localeData = await fetchRelativeTimeFormatData(languageTag); + (Intl.RelativeTimeFormat as any).__addLocaleData(localeData); + relativeTimeFormatLocaleData.add(languageTag); + } +} + +export function clearCaches() { + Object.keys(warned).forEach((key) => { + delete warned[key]; + }); + + Object.keys(missingMessageErrors).forEach((key) => { + delete missingMessageErrors[key]; + }); + + Object.keys(missingSubstitutionErrors).forEach((key) => { + delete missingSubstitutionErrors[key]; + }); + + relativeTimeFormatLocaleData.clear(); +} + +function applySubstitutions( + message: string, + substitutions: I18NSubstitution[], + key: string, + localeCode: string, + onError?: (error: Error) => void, +): I18NSubstitution[] { + const parts = message.split(/(\$\d)/gu); + + return parts.map((part: string) => { + const subMatch = part.match(/\$(\d)/u); + + if (!subMatch) { + return part; + } + + const substituteIndex = Number(subMatch[1]) - 1; + const substitution = substitutions[substituteIndex]; + + if (substitution === null || substitution === undefined) { + missingSubstitutionError(key, localeCode, onError); + } + + return substitutions?.[substituteIndex]; + }); +} + +function missingKeyError( + key: string, + localeCode: string, + onError?: (error: Error) => void, +) { + if (localeCode === FALLBACK_LOCALE && !missingMessageErrors[key]) { + const error = new Error( + `Unable to find value of key "${key}" for locale "${localeCode}"`, + ); + + missingMessageErrors[key] = error; + + onError?.(error); + log.error(error); + + if (process.env.IN_TEST) { + throw error; + } + } + + if (localeCode === FALLBACK_LOCALE || warned[localeCode]?.[key]) { + return; + } + + warned[localeCode] = warned[localeCode] ?? {}; + warned[localeCode][key] = true; + + log.warn( + `Translator - Unable to find value of key "${key}" for locale "${localeCode}"`, + ); +} + +function missingSubstitutionError( + key: string, + localeCode: string, + onError?: (error: Error) => void, +) { + if (missingSubstitutionErrors[localeCode]?.[key]) { + return; + } + + missingSubstitutionErrors[localeCode] = + missingSubstitutionErrors[localeCode] ?? {}; + + missingSubstitutionErrors[localeCode][key] = true; + + const error = new Error( + `Insufficient number of substitutions for key "${key}" with locale "${localeCode}"`, + ); + + log.error(error); + + onError?.(error); +} + +function hasSubstitutions(substitutions?: I18NSubstitution[]) { + return (substitutions?.length ?? 0) > 0; +} + +async function fetchRelativeTimeFormatData(languageTag: string): Promise { + const response = await fetchWithTimeout( + `./intl/${languageTag}/relative-time-format-data.json`, + ); + return await response.json(); +} diff --git a/tsconfig.json b/tsconfig.json index 0418262ab..aaea30b2c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,8 @@ "outDir": "tsout", "rootDir": ".", "sourceMap": true, - "strict": true + "strict": true, + "resolveJsonModule": true }, "exclude": [ "**/jest-coverage/**/*", diff --git a/ui/contexts/i18n.js b/ui/contexts/i18n.js index 296bc5497..47930e87a 100644 --- a/ui/contexts/i18n.js +++ b/ui/contexts/i18n.js @@ -1,12 +1,12 @@ import React, { Component, createContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { getMessage } from '../helpers/utils/i18n-helper'; import { getCurrentLocale, getCurrentLocaleMessages, getEnLocaleMessages, } from '../ducks/locale/locale'; +import { getMessage } from '../helpers/utils/i18n-helper'; export const I18nContext = createContext((key) => `[${key}]`); diff --git a/ui/helpers/utils/__snapshots__/i18n-helper.test.js.snap b/ui/helpers/utils/__snapshots__/i18n-helper.test.js.snap index 83c3b7851..8b5802da7 100644 --- a/ui/helpers/utils/__snapshots__/i18n-helper.test.js.snap +++ b/ui/helpers/utils/__snapshots__/i18n-helper.test.js.snap @@ -1,64 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`i18n helper getMessage should return the correct message when a single react substitution is made 1`] = ` +exports[`I18N Helper getMessage renders substitutions inside span if substitutions include React components 1`] = `
- Testing a react substitution -
- TEST_SUBSTITUTION_1 -
- . - -
-
-`; - -exports[`i18n helper getMessage should return the correct message when substituting a mix of react elements and strings 1`] = ` -
- - - Testing a mix - TEST_SUBSTITUTION_1 - of react substitutions
- TEST_SUBSTITUTION_3 + a1
- and string substitutions - TEST_SUBSTITUTION_2 - +
- TEST_SUBSTITUTION_4 + b2
- . - -
-
-`; - -exports[`i18n helper getMessage should return the correct message when two react substitutions are made 1`] = ` -
- - - Testing a react substitution -
- TEST_SUBSTITUTION_1 -
- and another -
- TEST_SUBSTITUTION_2 -
- . + c3
diff --git a/ui/helpers/utils/i18n-helper.test.js b/ui/helpers/utils/i18n-helper.test.js index 5fb26a9ab..8b47a6908 100644 --- a/ui/helpers/utils/i18n-helper.test.js +++ b/ui/helpers/utils/i18n-helper.test.js @@ -1,169 +1,93 @@ import React from 'react'; +import * as Sentry from '@sentry/browser'; +import { getMessage as getMessageShared } from '../../../shared/modules/i18n'; import { renderWithProvider } from '../../../test/lib/render-helpers'; import { getMessage } from './i18n-helper'; -describe('i18n helper', () => { - const TEST_LOCALE_CODE = 'TEST_LOCALE_CODE'; +jest.mock('../../../shared/modules/i18n'); +jest.mock('@sentry/browser'); - const TEST_KEY_1 = 'TEST_KEY_1'; - const TEST_KEY_2 = 'TEST_KEY_2'; - const TEST_KEY_3 = 'TEST_KEY_3'; - const TEST_KEY_4 = 'TEST_KEY_4'; - const TEST_KEY_5 = 'TEST_KEY_5'; - const TEST_KEY_6 = 'TEST_KEY_6'; - const TEST_KEY_6_HELPER = 'TEST_KEY_6_HELPER'; - const TEST_KEY_7 = 'TEST_KEY_7'; - const TEST_KEY_7_HELPER_1 = 'TEST_KEY_7_HELPER_1'; - const TEST_KEY_7_HELPER_2 = 'TEST_KEY_7_HELPER_2'; - const TEST_KEY_8 = 'TEST_KEY_8'; - const TEST_KEY_8_HELPER_1 = 'TEST_KEY_8_HELPER_1'; - const TEST_KEY_8_HELPER_2 = 'TEST_KEY_8_HELPER_2'; +const localeCodeMock = 'te'; +const keyMock = 'testKey'; +const localeMessagesMock = { [keyMock]: { message: 'testMessage' } }; +const errorMock = new Error('testError'); +const messageMock = 'testMessage'; - const TEST_SUBSTITUTION_1 = 'TEST_SUBSTITUTION_1'; - const TEST_SUBSTITUTION_2 = 'TEST_SUBSTITUTION_2'; - const TEST_SUBSTITUTION_3 = 'TEST_SUBSTITUTION_3'; - const TEST_SUBSTITUTION_4 = 'TEST_SUBSTITUTION_4'; - const TEST_SUBSTITUTION_5 = 'TEST_SUBSTITUTION_5'; - - const testLocaleMessages = { - [TEST_KEY_1]: { - message: 'This is a simple message.', - expectedResult: 'This is a simple message.', - }, - [TEST_KEY_2]: { - message: 'This is a message with a single non-react substitution $1.', - }, - [TEST_KEY_3]: { - message: 'This is a message with two non-react substitutions $1 and $2.', - }, - [TEST_KEY_4]: { - message: '$1 - $2 - $3 - $4 - $5', - }, - [TEST_KEY_5]: { - message: '$1 - $2 - $3', - }, - [TEST_KEY_6]: { - message: 'Testing a react substitution $1.', - }, - [TEST_KEY_6_HELPER]: { - message: TEST_SUBSTITUTION_1, - }, - [TEST_KEY_7]: { - message: 'Testing a react substitution $1 and another $2.', - }, - [TEST_KEY_7_HELPER_1]: { - message: TEST_SUBSTITUTION_1, - }, - [TEST_KEY_7_HELPER_2]: { - message: TEST_SUBSTITUTION_2, - }, - [TEST_KEY_8]: { - message: - 'Testing a mix $1 of react substitutions $2 and string substitutions $3 + $4.', - }, - [TEST_KEY_8_HELPER_1]: { - message: TEST_SUBSTITUTION_3, - }, - [TEST_KEY_8_HELPER_2]: { - message: TEST_SUBSTITUTION_4, - }, - }; - const t = getMessage.bind(null, TEST_LOCALE_CODE, testLocaleMessages); - - const TEST_SUBSTITUTION_6 = ( -
- {t(TEST_KEY_6_HELPER)} -
- ); - const TEST_SUBSTITUTION_7_1 = ( -
- {t(TEST_KEY_7_HELPER_1)} -
- ); - const TEST_SUBSTITUTION_7_2 = ( -
- {t(TEST_KEY_7_HELPER_2)} -
- ); - const TEST_SUBSTITUTION_8_1 = ( -
- {t(TEST_KEY_8_HELPER_1)} -
- ); - const TEST_SUBSTITUTION_8_2 = ( -
- {t(TEST_KEY_8_HELPER_2)} -
- ); +describe('I18N Helper', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); describe('getMessage', () => { - it('should return the exact message paired with key if there are no substitutions', () => { - const result = t(TEST_KEY_1); - expect(result).toStrictEqual('This is a simple message.'); - }); + it('returns value from getMessage in shared module', () => { + getMessageShared.mockReturnValue(messageMock); - it('should return the correct message when a single non-react substitution is made', () => { - const result = t(TEST_KEY_2, [TEST_SUBSTITUTION_1]); - expect(result).toStrictEqual( - `This is a message with a single non-react substitution ${TEST_SUBSTITUTION_1}.`, + expect( + getMessage(localeCodeMock, localeMessagesMock, keyMock), + ).toStrictEqual(messageMock); + + expect(getMessageShared).toHaveBeenCalledTimes(1); + expect(getMessageShared).toHaveBeenCalledWith( + localeCodeMock, + localeMessagesMock, + keyMock, + undefined, + expect.any(Function), + undefined, ); }); - it('should return the correct message when two non-react substitutions are made', () => { - const result = t(TEST_KEY_3, [TEST_SUBSTITUTION_1, TEST_SUBSTITUTION_2]); - expect(result).toStrictEqual( - `This is a message with two non-react substitutions ${TEST_SUBSTITUTION_1} and ${TEST_SUBSTITUTION_2}.`, + it('invokes getMessage from shared module with onError callback that logs Sentry exception', () => { + getMessage(localeCodeMock, localeMessagesMock, keyMock); + + const onErrorCallback = getMessageShared.mock.calls[0][4]; + onErrorCallback(errorMock); + + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith(errorMock); + }); + + it('does not provide custom join logic if only strings in substitutions', () => { + getMessage(localeCodeMock, localeMessagesMock, keyMock, ['a1', 'a2']); + + expect(getMessageShared).toHaveBeenCalledTimes(1); + expect(getMessageShared).toHaveBeenCalledWith( + localeCodeMock, + localeMessagesMock, + keyMock, + ['a1', 'a2'], + expect.any(Function), + undefined, ); }); - it('should return the correct message when multiple non-react substitutions are made', () => { - const result = t(TEST_KEY_4, [ - TEST_SUBSTITUTION_1, - TEST_SUBSTITUTION_2, - TEST_SUBSTITUTION_3, - TEST_SUBSTITUTION_4, - TEST_SUBSTITUTION_5, - ]); - expect(result).toStrictEqual( - `${TEST_SUBSTITUTION_1} - ${TEST_SUBSTITUTION_2} - ${TEST_SUBSTITUTION_3} - ${TEST_SUBSTITUTION_4} - ${TEST_SUBSTITUTION_5}`, + it('renders substitutions inside span if substitutions include React components', () => { + const substitution1 = ( +
+ a1 +
); - }); - it('should correctly render falsy substitutions', () => { - const result = t(TEST_KEY_4, [0, -0, '', false, NaN]); - expect(result).toStrictEqual('0 - 0 - - false - NaN'); - }); + const substitution2 = ( +
+ b2 +
+ ); - it('should render nothing for "null" and "undefined" substitutions', () => { - const result = t(TEST_KEY_5, [null, TEST_SUBSTITUTION_2]); - expect(result).toStrictEqual(` - ${TEST_SUBSTITUTION_2} - `); - }); + const substitution3 = 'c3'; - it('should return the correct message when a single react substitution is made', () => { - const result = t(TEST_KEY_6, [TEST_SUBSTITUTION_6]); - - const { container } = renderWithProvider(result); - - expect(container).toMatchSnapshot(); - }); - - it('should return the correct message when two react substitutions are made', () => { - const result = t(TEST_KEY_7, [ - TEST_SUBSTITUTION_7_1, - TEST_SUBSTITUTION_7_2, + getMessage(localeCodeMock, localeMessagesMock, keyMock, [ + substitution1, + substitution2, + substitution3, ]); - const { container } = renderWithProvider(result); + const joinCallback = getMessageShared.mock.calls[0][5]; - expect(container).toMatchSnapshot(); - }); - - it('should return the correct message when substituting a mix of react elements and strings', () => { - const result = t(TEST_KEY_8, [ - TEST_SUBSTITUTION_1, - TEST_SUBSTITUTION_8_1, - TEST_SUBSTITUTION_2, - TEST_SUBSTITUTION_8_2, + const result = joinCallback([ + substitution1, + substitution2, + substitution3, ]); const { container } = renderWithProvider(result); diff --git a/ui/helpers/utils/i18n-helper.tsx b/ui/helpers/utils/i18n-helper.tsx index 59c28d1e5..5ba68241b 100644 --- a/ui/helpers/utils/i18n-helper.tsx +++ b/ui/helpers/utils/i18n-helper.tsx @@ -1,46 +1,12 @@ -// cross-browser connection to extension i18n API import React from 'react'; -import log from 'loglevel'; -import { Json } from '@metamask/utils'; import * as Sentry from '@sentry/browser'; -import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; +import { + I18NMessageDict, + I18NSubstitution, + getMessage as getMessageShared, +} from '../../../shared/modules/i18n'; import { NETWORK_TYPES } from '../../../shared/constants/network'; -const fetchWithTimeout = getFetchWithTimeout(); - -// From app/_locales folders there is a messages.json file such as app/_locales/en, comes with key and translated results -// and we use as t('reject') to get the translated message in the codebase -// and in i18n lib, the translated message is an object (I18NMessage) with message & description - -// message is the string that will replace the translationKey, and that message may contain replacement variables such as $1, $2, etc. -// Description is key describing the usage of the message. -interface I18NMessage { - message: string; - description?: string; -} - -// The overall translation file is made of same entries -// translationKey (string) and the I18NMessage as the value. -interface I18NMessageDict { - [translationKey: string]: I18NMessage; -} - -// A parameterized type (or generic type) of maps that use the same structure (translationKey) key -interface I18NMessageDictMap { - [translationKey: string]: R; -} - -const warned: { [localeCode: string]: I18NMessageDictMap } = {}; -const missingMessageErrors: I18NMessageDictMap = {}; -const missingSubstitutionErrors: { - [localeCode: string]: I18NMessageDictMap; -} = {}; - -function getHasSubstitutions( - substitutions?: string[], -): substitutions is string[] { - return (substitutions?.length ?? 0) > 0; -} - /** * Returns a localized message for the given key * @@ -56,116 +22,29 @@ export const getMessage = ( key: string, substitutions?: string[], ): JSX.Element | string | null => { - if (!localeMessages) { - return null; - } - if (!localeMessages[key]) { - if (localeCode === 'en') { - if (!missingMessageErrors[key]) { - missingMessageErrors[key] = new Error( - `Unable to find value of key "${key}" for locale "${localeCode}"`, - ); - Sentry.captureException(missingMessageErrors[key]); - log.error(missingMessageErrors[key]); - if (process.env.IN_TEST) { - throw missingMessageErrors[key]; - } - } - } else if (!warned[localeCode] || !warned[localeCode][key]) { - if (!warned[localeCode]) { - warned[localeCode] = {}; - } - warned[localeCode][key] = true; - log.warn( - `Translator - Unable to find value of key "${key}" for locale "${localeCode}"`, - ); - } - return null; - } - - const hasSubstitutions = getHasSubstitutions(substitutions); - const hasReactSubstitutions = - hasSubstitutions && - substitutions?.some( - (element) => - element !== null && - (typeof element === 'function' || typeof element === 'object'), - ); - const entry = localeMessages[key]; - const phrase = entry.message; - // perform substitutions - if (hasSubstitutions) { - const parts = phrase.split(/(\$\d)/gu); - - const substitutedParts = parts.map((part: string) => { - const subMatch = part.match(/\$(\d)/u); - if (!subMatch) { - return part; - } - const substituteIndex = Number(subMatch[1]) - 1; - if ( - (substitutions[substituteIndex] === null || - substitutions[substituteIndex] === undefined) && - !missingSubstitutionErrors[localeCode]?.[key] - ) { - if (!missingSubstitutionErrors[localeCode]) { - missingSubstitutionErrors[localeCode] = {}; - } - missingSubstitutionErrors[localeCode][key] = true; - const error = new Error( - `Insufficient number of substitutions for key "${key}" with locale "${localeCode}"`, - ); - log.error(error); - Sentry.captureException(error); - } - return substitutions?.[substituteIndex]; - }); - - return hasReactSubstitutions ? ( - {substitutedParts} - ) : ( - substitutedParts.join('') - ); - } - return phrase; -}; - -export async function fetchLocale( - localeCode: string, -): Promise { - try { - const response = await fetchWithTimeout( - `./_locales/${localeCode}/messages.json`, - ); - return await response.json(); - } catch (error) { - log.error(`failed to fetch ${localeCode} locale because of ${error}`); - return {}; - } -} - -const relativeTimeFormatLocaleData = new Set(); - -export async function loadRelativeTimeFormatLocaleData( - localeCode: string, -): Promise { - const languageTag = localeCode.split('_')[0]; - if ( - Intl.RelativeTimeFormat && - typeof (Intl.RelativeTimeFormat as any).__addLocaleData === 'function' && - !relativeTimeFormatLocaleData.has(languageTag) - ) { - const localeData = await fetchRelativeTimeFormatData(languageTag); - (Intl.RelativeTimeFormat as any).__addLocaleData(localeData); - } -} - -async function fetchRelativeTimeFormatData(languageTag: string): Promise { - const response = await fetchWithTimeout( - `./intl/${languageTag}/relative-time-format-data.json`, + const hasReactSubstitutions = substitutions?.some( + (element) => + element !== null && + (typeof element === 'function' || typeof element === 'object'), ); - return await response.json(); -} + + const join = hasReactSubstitutions + ? (parts: I18NSubstitution[]) => {parts} + : undefined; + + const onError = (error: Error) => { + Sentry.captureException(error); + }; + + return getMessageShared( + localeCode, + localeMessages, + key, + substitutions, + onError, + join, + ); +}; export function getNetworkLabelKey(network: string): string { if (network === NETWORK_TYPES.LINEA_GOERLI) { diff --git a/ui/hooks/useTransactionDisplayData.test.js b/ui/hooks/useTransactionDisplayData.test.js index d925a392b..9399acfb9 100644 --- a/ui/hooks/useTransactionDisplayData.test.js +++ b/ui/hooks/useTransactionDisplayData.test.js @@ -15,7 +15,6 @@ import { getNativeCurrency, getNfts, } from '../ducks/metamask/metamask'; -import { getMessage } from '../helpers/utils/i18n-helper'; import messages from '../../app/_locales/en/messages.json'; import { ASSET_ROUTE, DEFAULT_ROUTE } from '../helpers/constants/routes'; import { CHAIN_IDS } from '../../shared/constants/network'; @@ -25,6 +24,7 @@ import { TransactionStatus, } from '../../shared/constants/transaction'; import { formatDateWithYearContext } from '../helpers/utils/util'; +import { getMessage } from '../helpers/utils/i18n-helper'; import * as i18nhooks from './useI18nContext'; import * as useTokenFiatAmountHooks from './useTokenFiatAmount'; import { useTransactionDisplayData } from './useTransactionDisplayData'; diff --git a/ui/index.test.js b/ui/index.test.js index 16dd70d96..314daf5d0 100644 --- a/ui/index.test.js +++ b/ui/index.test.js @@ -29,7 +29,7 @@ const esMessages = { }, }; -jest.mock('./helpers/utils/i18n-helper', () => ({ +jest.mock('../shared/modules/i18n', () => ({ fetchLocale: jest.fn((locale) => (locale === 'en' ? enMessages : esMessages)), loadRelativeTimeFormatLocaleData: jest.fn(), })); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 235d97e0b..bfd6415d5 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -72,7 +72,7 @@ import { NOTIFICATIONS_EXPIRATION_DELAY } from '../helpers/constants/notificatio import { fetchLocale, loadRelativeTimeFormatLocaleData, -} from '../helpers/utils/i18n-helper'; +} from '../../shared/modules/i18n'; import { decimalToHex } from '../../shared/modules/conversion.utils'; import { TxGasFees, PriorityLevels } from '../../shared/constants/gas'; import { From 39089e0f4c3e1b356e6629f800ca323999891087 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Tue, 20 Jun 2023 15:37:09 +0200 Subject: [PATCH 007/118] Accept SignController approval request from frontend (#19184) --- ...ture-controller-npm-3.0.0-8771b6885e.patch | 18 - app/scripts/controllers/decrypt-message.ts | 2 +- .../controllers/encryption-public-key.ts | 2 +- app/scripts/metamask-controller.js | 27 +- lavamoat/browserify/beta/policy.json | 3 +- lavamoat/browserify/desktop/policy.json | 3 +- lavamoat/browserify/flask/policy.json | 3 +- lavamoat/browserify/main/policy.json | 3 +- package.json | 7 +- .../qr-hardware-popover.js | 32 +- .../signature-request-original-warning.js | 1 + .../signature-request-original.component.js | 60 ++-- .../signature-request-original.container.js | 52 ++- .../signature-request-original.test.js | 32 +- .../signature-request-siwe.js | 67 ++-- .../signature-request.component.js | 44 ++- .../signature-request.container.js | 82 ++--- .../signature-request.container.test.js | 32 +- ui/pages/confirm-signature-request/index.js | 46 --- ui/store/actions.test.js | 225 ------------ ui/store/actions.ts | 321 ++---------------- .../institutional/institution-actions.test.js | 8 +- ui/store/institutional/institution-actions.ts | 12 +- ui/store/store.ts | 2 +- yarn.lock | 45 +-- 25 files changed, 281 insertions(+), 848 deletions(-) delete mode 100644 .yarn/patches/@metamask-signature-controller-npm-3.0.0-8771b6885e.patch diff --git a/.yarn/patches/@metamask-signature-controller-npm-3.0.0-8771b6885e.patch b/.yarn/patches/@metamask-signature-controller-npm-3.0.0-8771b6885e.patch deleted file mode 100644 index 4a2682995..000000000 --- a/.yarn/patches/@metamask-signature-controller-npm-3.0.0-8771b6885e.patch +++ /dev/null @@ -1,18 +0,0 @@ -diff --git a/dist/SignatureController.js b/dist/SignatureController.js -index b39d274f4547ab4e8b647293199ec21c4a9e38ca..288e55c97c3e4a234874dd8b8986ba77576b0dc4 100644 ---- a/dist/SignatureController.js -+++ b/dist/SignatureController.js -@@ -308,12 +308,12 @@ _SignatureController_keyringController = new WeakMap(), _SignatureController_isE - const messageId = msgParams.metamaskId; - try { - const cleanMessageParams = yield messageManager.approveMessage(msgParams); -+ __classPrivateFieldGet(this, _SignatureController_instances, "m", _SignatureController_acceptApproval).call(this, messageId); - const signature = yield getSignature(cleanMessageParams); - this.hub.emit(`${methodName}:signed`, { signature, messageId }); - if (!cleanMessageParams.deferSetAsSigned) { - messageManager.setMessageStatusSigned(messageId, signature); - } -- __classPrivateFieldGet(this, _SignatureController_instances, "m", _SignatureController_acceptApproval).call(this, messageId); - return __classPrivateFieldGet(this, _SignatureController_getAllState, "f").call(this); - } - catch (error) { diff --git a/app/scripts/controllers/decrypt-message.ts b/app/scripts/controllers/decrypt-message.ts index b9f37fea2..fee01257f 100644 --- a/app/scripts/controllers/decrypt-message.ts +++ b/app/scripts/controllers/decrypt-message.ts @@ -45,7 +45,7 @@ export type CoreMessage = AbstractMessage & { }; export type StateMessage = Required< - Omit + Omit >; export type DecryptMessageControllerState = { diff --git a/app/scripts/controllers/encryption-public-key.ts b/app/scripts/controllers/encryption-public-key.ts index 904bed476..da50f1afe 100644 --- a/app/scripts/controllers/encryption-public-key.ts +++ b/app/scripts/controllers/encryption-public-key.ts @@ -45,7 +45,7 @@ export type CoreMessage = AbstractMessage & { }; export type StateMessage = Required< - Omit + Omit > & { msgParams: string; }; diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index bb6423a28..9613a9aa6 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1268,11 +1268,7 @@ export default class MetamaskController extends EventEmitter { this.signatureController = new SignatureController({ messenger: this.controllerMessenger.getRestricted({ name: 'SignatureController', - allowedActions: [ - `${this.approvalController.name}:addRequest`, - `${this.approvalController.name}:acceptRequest`, - `${this.approvalController.name}:rejectRequest`, - ], + allowedActions: [`${this.approvalController.name}:addRequest`], }), keyringController: this.keyringController, isEthSignEnabled: () => @@ -2258,27 +2254,6 @@ export default class MetamaskController extends EventEmitter { updatePreviousGasParams: txController.updatePreviousGasParams.bind(txController), - // signatureController - signMessage: this.signatureController.signMessage.bind( - this.signatureController, - ), - cancelMessage: this.signatureController.cancelMessage.bind( - this.signatureController, - ), - signPersonalMessage: this.signatureController.signPersonalMessage.bind( - this.signatureController, - ), - cancelPersonalMessage: - this.signatureController.cancelPersonalMessage.bind( - this.signatureController, - ), - signTypedMessage: this.signatureController.signTypedMessage.bind( - this.signatureController, - ), - cancelTypedMessage: this.signatureController.cancelTypedMessage.bind( - this.signatureController, - ), - // decryptMessageController decryptMessage: this.decryptMessageController.decryptMessage.bind( this.decryptMessageController, diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index c43c1c97b..929c69e1b 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -1718,7 +1718,8 @@ "browserify>buffer": true, "browserify>events": true, "eth-rpc-errors": true, - "ethereumjs-util": true + "ethereumjs-util": true, + "lodash": true } }, "@metamask/smart-transactions-controller": { diff --git a/lavamoat/browserify/desktop/policy.json b/lavamoat/browserify/desktop/policy.json index f80fbb328..d470098a1 100644 --- a/lavamoat/browserify/desktop/policy.json +++ b/lavamoat/browserify/desktop/policy.json @@ -1908,7 +1908,8 @@ "browserify>buffer": true, "browserify>events": true, "eth-rpc-errors": true, - "ethereumjs-util": true + "ethereumjs-util": true, + "lodash": true } }, "@metamask/smart-transactions-controller": { diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index f80fbb328..d470098a1 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -1908,7 +1908,8 @@ "browserify>buffer": true, "browserify>events": true, "eth-rpc-errors": true, - "ethereumjs-util": true + "ethereumjs-util": true, + "lodash": true } }, "@metamask/smart-transactions-controller": { diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index c43c1c97b..929c69e1b 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -1718,7 +1718,8 @@ "browserify>buffer": true, "browserify>events": true, "eth-rpc-errors": true, - "ethereumjs-util": true + "ethereumjs-util": true, + "lodash": true } }, "@metamask/smart-transactions-controller": { diff --git a/package.json b/package.json index 62e63668e..e0cebbb32 100644 --- a/package.json +++ b/package.json @@ -197,8 +197,7 @@ "fast-json-patch@^3.1.1": "patch:fast-json-patch@npm%3A3.1.1#./.yarn/patches/fast-json-patch-npm-3.1.1-7e8bb70a45.patch", "request@^2.83.0": "patch:request@npm%3A2.88.2#./.yarn/patches/request-npm-2.88.2-f4a57c72c4.patch", "request@^2.88.2": "patch:request@npm%3A2.88.2#./.yarn/patches/request-npm-2.88.2-f4a57c72c4.patch", - "request@^2.85.0": "patch:request@npm%3A2.88.2#./.yarn/patches/request-npm-2.88.2-f4a57c72c4.patch", - "@metamask/signature-controller@^3.0.0": "patch:@metamask/signature-controller@npm%3A3.0.0#./.yarn/patches/@metamask-signature-controller-npm-3.0.0-8771b6885e.patch" + "request@^2.85.0": "patch:request@npm%3A2.88.2#./.yarn/patches/request-npm-2.88.2-f4a57c72c4.patch" }, "dependencies": { "@actions/core": "^1.10.0", @@ -249,7 +248,7 @@ "@metamask/jazzicon": "^2.0.0", "@metamask/key-tree": "^7.0.0", "@metamask/logo": "^3.1.1", - "@metamask/message-manager": "^6.0.0", + "@metamask/message-manager": "^7.0.0", "@metamask/metamask-eth-abis": "^3.0.0", "@metamask/notification-controller": "^3.0.0", "@metamask/obs-store": "^8.1.0", @@ -262,7 +261,7 @@ "@metamask/rpc-methods-flask": "npm:@metamask/rpc-methods@0.34.0-flask.1", "@metamask/safe-event-emitter": "^2.0.0", "@metamask/scure-bip39": "^2.0.3", - "@metamask/signature-controller": "^3.0.0", + "@metamask/signature-controller": "^4.0.1", "@metamask/slip44": "^3.0.0", "@metamask/smart-transactions-controller": "^3.1.0", "@metamask/snaps-controllers": "^0.32.2", diff --git a/ui/components/app/qr-hardware-popover/qr-hardware-popover.js b/ui/components/app/qr-hardware-popover/qr-hardware-popover.js index c3875c4e8..40d577380 100644 --- a/ui/components/app/qr-hardware-popover/qr-hardware-popover.js +++ b/ui/components/app/qr-hardware-popover/qr-hardware-popover.js @@ -1,5 +1,6 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { ethErrors, serializeError } from 'eth-rpc-errors'; import { getCurrentQRHardwareState } from '../../../selectors'; import Popover from '../../ui/popover'; import { useI18nContext } from '../../../hooks/useI18nContext'; @@ -7,11 +8,8 @@ import { cancelSyncQRHardware as cancelSyncQRHardwareAction, cancelQRHardwareSignRequest as cancelQRHardwareSignRequestAction, cancelTx, - cancelPersonalMsg, - cancelMsg, - cancelTypedMsg, + rejectPendingApproval, } from '../../../store/actions'; -import { MESSAGE_TYPE } from '../../../../shared/constants/app'; import QRHardwareWalletImporter from './qr-hardware-wallet-importer'; import QRHardwareSignRequest from './qr-hardware-sign-request'; @@ -43,25 +41,13 @@ const QRHardwarePopover = () => { ); const signRequestCancel = useCallback(() => { - let action = cancelTx; - switch (_txData.type) { - case MESSAGE_TYPE.PERSONAL_SIGN: { - action = cancelPersonalMsg; - break; - } - case MESSAGE_TYPE.ETH_SIGN: { - action = cancelMsg; - break; - } - case MESSAGE_TYPE.ETH_SIGN_TYPED_DATA: { - action = cancelTypedMsg; - break; - } - default: { - action = cancelTx; - } - } - dispatch(action(_txData)); + dispatch( + rejectPendingApproval( + _txData.id, + serializeError(ethErrors.provider.userRejectedRequest()), + ), + ); + dispatch(cancelTx(_txData)); dispatch(cancelQRHardwareSignRequestAction()); }, [dispatch, _txData]); diff --git a/ui/components/app/signature-request-original/signature-request-original-warning/signature-request-original-warning.js b/ui/components/app/signature-request-original/signature-request-original-warning/signature-request-original-warning.js index 6de7757e9..13ac14b76 100644 --- a/ui/components/app/signature-request-original/signature-request-original-warning/signature-request-original-warning.js +++ b/ui/components/app/signature-request-original/signature-request-original-warning/signature-request-original-warning.js @@ -96,6 +96,7 @@ const SignatureRequestOriginalWarning = ({ + ))} + + ); +} diff --git a/ui/components/app/srp-quiz-modal/QuizContent/index.ts b/ui/components/app/srp-quiz-modal/QuizContent/index.ts new file mode 100644 index 000000000..537995246 --- /dev/null +++ b/ui/components/app/srp-quiz-modal/QuizContent/index.ts @@ -0,0 +1 @@ +export { default } from './QuizContent'; diff --git a/ui/components/app/srp-quiz-modal/SRPQuiz/SRPQuiz.stories.tsx b/ui/components/app/srp-quiz-modal/SRPQuiz/SRPQuiz.stories.tsx new file mode 100644 index 000000000..495c4e14a --- /dev/null +++ b/ui/components/app/srp-quiz-modal/SRPQuiz/SRPQuiz.stories.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { StoryFn, Meta } from '@storybook/react'; +import { useArgs } from '@storybook/client-api'; +import { Button } from '../../../component-library'; +import SRPQuiz from '.'; + +export default { + title: 'Components/App/SRPQuizModal', + component: SRPQuiz, + argTypes: { + isShowingModal: { + control: 'boolean', + }, + }, +} as Meta; + +export const DefaultStory: StoryFn = () => { + const [{ isShowingModal }, updateArgs] = useArgs(); + + return ( + <> + + {isShowingModal && ( + updateArgs({ isShowingModal: false })} + /> + )} + + ); +}; + +DefaultStory.storyName = 'Default'; diff --git a/ui/components/app/srp-quiz-modal/SRPQuiz/SRPQuiz.test.js b/ui/components/app/srp-quiz-modal/SRPQuiz/SRPQuiz.test.js new file mode 100644 index 000000000..d1e7ae395 --- /dev/null +++ b/ui/components/app/srp-quiz-modal/SRPQuiz/SRPQuiz.test.js @@ -0,0 +1,95 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import mockState from '../../../../../test/data/mock-state.json'; +import { renderWithProvider } from '../../../../../test/jest'; +import ZENDESK_URLS from '../../../../helpers/constants/zendesk-url'; +import configureStore from '../../../../store/store'; +import { QuizStage } from '../types'; +import SRPQuiz from './SRPQuiz'; + +const store = configureStore({ + metamask: { + ...mockState.metamask, + }, +}); + +let openTabSpy; + +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + return { + ...original, + useHistory: () => ({ + push: jest.fn(), + }), + }; +}); + +async function waitForStage(stage) { + return await waitFor(() => { + expect(screen.getByTestId(`srp_stage_${stage}`)).toBeInTheDocument(); + }); +} + +function clickButton(id) { + fireEvent.click(screen.getByTestId(id)); +} + +describe('srp-reveal-quiz', () => { + beforeAll(() => { + global.platform = { openTab: jest.fn() }; + openTabSpy = jest.spyOn(global.platform, 'openTab'); + }); + + it('should go through the full sequence of steps', async () => { + renderWithProvider(, store); + + expect(screen.queryByTestId('srp-quiz-get-started')).toBeInTheDocument(); + + expect( + screen.queryByTestId('srp-quiz-right-answer'), + ).not.toBeInTheDocument(); + + clickButton('srp-quiz-learn-more'); + + await waitFor(() => + expect(openTabSpy).toHaveBeenCalledWith({ + url: expect.stringMatching(ZENDESK_URLS.PASSWORD_AND_SRP_ARTICLE), + }), + ); + + clickButton('srp-quiz-get-started'); + + await waitForStage(QuizStage.questionOne); + + clickButton('srp-quiz-wrong-answer'); + + await waitForStage(QuizStage.wrongAnswerQuestionOne); + + clickButton('srp-quiz-try-again'); + + await waitForStage(QuizStage.questionOne); + + clickButton('srp-quiz-right-answer'); + + await waitForStage(QuizStage.rightAnswerQuestionOne); + + clickButton('srp-quiz-continue'); + + await waitForStage(QuizStage.questionTwo); + + clickButton('srp-quiz-wrong-answer'); + + await waitForStage(QuizStage.wrongAnswerQuestionTwo); + + clickButton('srp-quiz-try-again'); + + await waitForStage(QuizStage.questionTwo); + + clickButton('srp-quiz-right-answer'); + + await waitForStage(QuizStage.rightAnswerQuestionTwo); + + clickButton('srp-quiz-continue'); + }); +}); diff --git a/ui/components/app/srp-quiz-modal/SRPQuiz/SRPQuiz.tsx b/ui/components/app/srp-quiz-modal/SRPQuiz/SRPQuiz.tsx new file mode 100644 index 000000000..44f3fd29b --- /dev/null +++ b/ui/components/app/srp-quiz-modal/SRPQuiz/SRPQuiz.tsx @@ -0,0 +1,298 @@ +/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, import/no-commonjs */ +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { + MetaMetricsEventCategory, + MetaMetricsEventKeyType, + MetaMetricsEventName, +} from '../../../../../shared/constants/metametrics'; +import { MetaMetricsContext } from '../../../../contexts/metametrics'; +import { + BlockSize, + Display, + FlexDirection, + IconColor, + TextAlign, +} from '../../../../helpers/constants/design-system'; +import { REVEAL_SEED_ROUTE } from '../../../../helpers/constants/routes'; +import ZENDESK_URLS from '../../../../helpers/constants/zendesk-url'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { + BUTTON_SIZES, + BUTTON_VARIANT, + Icon, + IconName, + IconSize, + Modal, + ModalContent, + ModalHeader, + ModalOverlay, +} from '../../../component-library'; +import QuizContent from '../QuizContent'; +import { JSXDict, QuizStage } from '../types'; + +const wrongAnswerIcon = ( + +); + +const rightAnswerIcon = ( + +); + +const openSupportArticle = (): void => { + global.platform.openTab({ + url: ZENDESK_URLS.PASSWORD_AND_SRP_ARTICLE, + }); +}; + +export default function SRPQuiz(props: any) { + const [stage, setStage] = useState(QuizStage.introduction); + + const trackEvent = useContext(MetaMetricsContext); + const history = useHistory(); + const t = useI18nContext(); + + // This should not be a state variable, because it's derivable from the state variable `stage` + // (Making it a state variable forces the component to render twice) + let title = ''; + + // Using a dictionary of JSX elements eliminates the need for a switch statement + const stages: JSXDict = {}; + + stages[QuizStage.introduction] = () => { + title = t('srpSecurityQuizTitle'); + return ( + setStage(QuizStage.questionOne), + variant: BUTTON_VARIANT.PRIMARY, + size: BUTTON_SIZES.LG, + 'data-testid': 'srp-quiz-get-started', + }, + { + label: t('learnMoreUpperCase'), + onClick: openSupportArticle, + variant: BUTTON_VARIANT.LINK, + 'data-testid': 'srp-quiz-learn-more', + }, + ]} + /> + ); + }; + + stages[QuizStage.questionOne] = () => { + title = `1 ${t('ofTextNofM')} 2`; + return ( + setStage(QuizStage.wrongAnswerQuestionOne), + variant: BUTTON_VARIANT.SECONDARY, + size: BUTTON_SIZES.LG, + 'data-testid': 'srp-quiz-wrong-answer', + }, + { + label: t('srpSecurityQuizQuestionOneRightAnswer'), + onClick: () => setStage(QuizStage.rightAnswerQuestionOne), + variant: BUTTON_VARIANT.SECONDARY, + size: BUTTON_SIZES.LG, + 'data-testid': 'srp-quiz-right-answer', + }, + { + label: t('learnMoreUpperCase'), + onClick: openSupportArticle, + variant: BUTTON_VARIANT.LINK, + }, + ]} + /> + ); + }; + + stages[QuizStage.rightAnswerQuestionOne] = () => { + title = `1 ${t('ofTextNofM')} 2`; + return ( + setStage(QuizStage.questionTwo), + variant: BUTTON_VARIANT.PRIMARY, + size: BUTTON_SIZES.LG, + 'data-testid': 'srp-quiz-continue', + }, + { + label: t('learnMoreUpperCase'), + onClick: openSupportArticle, + variant: BUTTON_VARIANT.LINK, + }, + ]} + /> + ); + }; + + stages[QuizStage.wrongAnswerQuestionOne] = () => { + title = `1 ${t('ofTextNofM')} 2`; + return ( + setStage(QuizStage.questionOne), + variant: BUTTON_VARIANT.PRIMARY, + size: BUTTON_SIZES.LG, + 'data-testid': 'srp-quiz-try-again', + }, + { + label: t('learnMoreUpperCase'), + onClick: openSupportArticle, + variant: BUTTON_VARIANT.LINK, + }, + ]} + /> + ); + }; + + stages[QuizStage.questionTwo] = () => { + title = `2 ${t('ofTextNofM')} 2`; + return ( + setStage(QuizStage.rightAnswerQuestionTwo), + variant: BUTTON_VARIANT.SECONDARY, + size: BUTTON_SIZES.LG, + 'data-testid': 'srp-quiz-right-answer', + }, + { + label: t('srpSecurityQuizQuestionTwoWrongAnswer'), + onClick: () => setStage(QuizStage.wrongAnswerQuestionTwo), + variant: BUTTON_VARIANT.SECONDARY, + size: BUTTON_SIZES.LG, + 'data-testid': 'srp-quiz-wrong-answer', + }, + { + label: t('learnMoreUpperCase'), + onClick: openSupportArticle, + variant: BUTTON_VARIANT.LINK, + }, + ]} + /> + ); + }; + + stages[QuizStage.rightAnswerQuestionTwo] = () => { + title = `2 ${t('ofTextNofM')} 2`; + return ( + history.push(REVEAL_SEED_ROUTE), + variant: BUTTON_VARIANT.PRIMARY, + size: BUTTON_SIZES.LG, + 'data-testid': 'srp-quiz-continue', + }, + { + label: t('learnMoreUpperCase'), + onClick: openSupportArticle, + variant: BUTTON_VARIANT.LINK, + }, + ]} + /> + ); + }; + + stages[QuizStage.wrongAnswerQuestionTwo] = () => { + title = `2 ${t('ofTextNofM')} 2`; + return ( + setStage(QuizStage.questionTwo), + variant: BUTTON_VARIANT.PRIMARY, + size: BUTTON_SIZES.LG, + 'data-testid': 'srp-quiz-try-again', + }, + { + label: t('learnMoreUpperCase'), + onClick: openSupportArticle, + variant: BUTTON_VARIANT.LINK, + }, + ]} + /> + ); + }; + + // trackEvent shortcut specific to the SRP quiz + const trackEventSrp = useCallback((location) => { + trackEvent( + { + category: MetaMetricsEventCategory.Keys, + event: MetaMetricsEventName.KeyExportSelected, + properties: { + key_type: MetaMetricsEventKeyType.Srp, + location, + }, + }, + {}, + ); + }, []); + + useEffect(() => { + trackEventSrp(`stage_${stage}`); // Call MetaMetrics based on the current stage + }, [stage]); // Only call this when the stage changes + + const quizContent = stages[stage](); // Pick the content using the right stage from the JSXDict + + return ( + + + + + {title} + + + {quizContent} + + + ); +} diff --git a/ui/components/app/srp-quiz-modal/SRPQuiz/index.ts b/ui/components/app/srp-quiz-modal/SRPQuiz/index.ts new file mode 100644 index 000000000..8fd4c830a --- /dev/null +++ b/ui/components/app/srp-quiz-modal/SRPQuiz/index.ts @@ -0,0 +1 @@ +export { default } from './SRPQuiz'; diff --git a/ui/components/app/srp-quiz-modal/index.ts b/ui/components/app/srp-quiz-modal/index.ts new file mode 100644 index 000000000..8fd4c830a --- /dev/null +++ b/ui/components/app/srp-quiz-modal/index.ts @@ -0,0 +1 @@ +export { default } from './SRPQuiz'; diff --git a/ui/components/app/srp-quiz-modal/types.ts b/ui/components/app/srp-quiz-modal/types.ts new file mode 100644 index 000000000..7c83dee67 --- /dev/null +++ b/ui/components/app/srp-quiz-modal/types.ts @@ -0,0 +1,40 @@ +export enum QuizStage { + introduction = 'introduction', + questionOne = 'question_one', + wrongAnswerQuestionOne = 'wrong_answer_question_one', + rightAnswerQuestionOne = 'right_answer_question_one', + questionTwo = 'question_two', + wrongAnswerQuestionTwo = 'wrong_answer_question_two', + rightAnswerQuestionTwo = 'right_answer_question_two', +} + +export interface IQuizInformationProps { + /** + * The icon to display in the modal should use component + */ + icon?: any; + /** + * The image to display in the modal + */ + image?: string; + /** + * The text content to go inside of the component + */ + content: string; + /** + * More text content to go inside of the component + */ + moreContent?: string; + /** + * Array of - - - + Reveal Secret Recovery Phrase +