diff --git a/src/popup/index.js b/src/popup/index.js index 0021a30..8e4ad16 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -64,8 +64,8 @@ window.addEventListener("pagehide", () => pageClosed.abort()); let refreshInFlight = false; -// The ten-second refresh init() starts, stopped when the popup moves to the -// recovery screen: there is no profile left to refresh. +// The ten-second refresh startRefresh() starts, stopped when the popup moves to +// the recovery screen: there is no profile left to refresh. let refreshTimer = null; async function doRefreshAndRender() { @@ -99,9 +99,23 @@ async function doRefreshAndRender() { } } +// Refreshes now and every ten seconds from then on. Called once a wallet +// exists: by init() when the popup opens with one, and by AddWallet when it +// adds one, which in a popup that opened on Welcome is the first +// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the refresh already +// running it starts no second one, and on the recovery screen it does nothing. +function startRefresh() { + if (!$("view-state-recovery").classList.contains("hidden")) return; + doRefreshAndRender(); + if (refreshTimer === null) { + refreshTimer = setInterval(doRefreshAndRender, 10000); + } +} + const ctx = { renderWalletList, doRefreshAndRender, + startRefresh, pageClosed: pageClosed.signal, showAddWalletView: () => { pushCurrentView(); @@ -318,8 +332,7 @@ async function init() { } else { renderWalletList(); restoreView(); - doRefreshAndRender(); - refreshTimer = setInterval(doRefreshAndRender, 10000); + startRefresh(); } } diff --git a/src/popup/views/addWallet.js b/src/popup/views/addWallet.js index 1e5a8e5..a339dae 100644 --- a/src/popup/views/addWallet.js +++ b/src/popup/views/addWallet.js @@ -234,7 +234,7 @@ async function importMnemonic(ctx) { showFlash("Ready.", 1000); } - ctx.doRefreshAndRender(); + ctx.startRefresh(); } async function importPrivateKey(ctx) { @@ -274,7 +274,7 @@ async function importPrivateKey(ctx) { ctx.renderWalletList(); showView("main"); - ctx.doRefreshAndRender(); + ctx.startRefresh(); } async function importXprvKey(ctx) { @@ -368,7 +368,7 @@ async function importXprvKey(ctx) { showFlash("Ready.", 1000); } - ctx.doRefreshAndRender(); + ctx.startRefresh(); } function init(ctx) { diff --git a/tests/addressKey.test.js b/tests/addressKey.test.js index 631cfe5..afdc961 100644 --- a/tests/addressKey.test.js +++ b/tests/addressKey.test.js @@ -230,7 +230,7 @@ beforeEach(() => { addWallet.init({ renderWalletList: () => {}, - doRefreshAndRender: () => {}, + startRefresh: () => {}, pageClosed: new AbortController().signal, }); confirmTx.init({}); diff --git a/tests/addressScanCancelled.test.js b/tests/addressScanCancelled.test.js index febfd63..48815d0 100644 --- a/tests/addressScanCancelled.test.js +++ b/tests/addressScanCancelled.test.js @@ -94,7 +94,7 @@ describe.each([ async function create(pageClosed) { addWallet.init({ renderWalletList: () => {}, - doRefreshAndRender: () => {}, + startRefresh: () => {}, pageClosed, }); element("tab-" + mode).listeners.click(); diff --git a/tests/firstWalletRefresh.test.js b/tests/firstWalletRefresh.test.js new file mode 100644 index 0000000..232cb30 --- /dev/null +++ b/tests/firstWalletRefresh.test.js @@ -0,0 +1,135 @@ +// A popup that opened with no wallet never started the ten-second refresh, so +// after the first wallet was added Home went on showing the balance it had then +// until the popup was reopened +// (https://git.eeqj.de/sneak/AutistMask/issues/530). Each test boots the real +// popup, adds wallets through AddWallet, and runs the intervals the popup set +// with tick(). The balance stand-in writes what the chain holds onto every +// address, as the real refreshBalances() does. + +const { HDNodeWallet, Mnemonic } = require("ethers"); + +const { + bootPopup, + cleanupPopup, + unversionedValidProfile, + ADDRESS, +} = require("./support/popupBoot"); + +const PHRASE = + "abandon abandon abandon abandon abandon abandon " + + "abandon abandon abandon abandon abandon about"; +const PRIVATE_KEY = "0x" + "11".repeat(32); +const XPRV = HDNodeWallet.fromSeed( + Mnemonic.fromPhrase(PHRASE).computeSeed(), +).extendedKey; +const PASSWORD = "correct horse battery staple"; + +const TABS = [ + { tab: "tab-mnemonic", field: "wallet-mnemonic", secret: PHRASE }, + { tab: "tab-privkey", field: "import-private-key", secret: PRIVATE_KEY }, + { tab: "tab-xprv", field: "import-xprv-key", secret: XPRV }, +]; + +// A record this build cannot read: its wallets are a string. +const UNREADABLE = { + hasWallet: true, + wallets: ADDRESS, + activeAddress: ADDRESS, +}; + +// The ETH balance the chain holds for every address, in ether. +let chainBalance = "0.0"; + +// Boots the popup over `stored`, with the balance stand-in reading the chain. +async function boot(stored) { + const page = await bootPopup(stored); + const { refreshBalances } = require("../src/shared/balances"); + refreshBalances.mockImplementation(async (wallets) => { + for (const wallet of wallets) { + for (const addr of wallet.addresses) addr.balance = chainBalance; + } + }); + return { page, refreshBalances }; +} + +// Fills in the open AddWallet screen and presses "Import". +async function importOn(page, { tab, field, secret }) { + await page.click(tab); + page.node(field).value = secret; + page.node("add-wallet-password").value = PASSWORD; + page.node("add-wallet-password-confirm").value = PASSWORD; + await page.click("btn-add-wallet-confirm"); +} + +beforeEach(() => { + chainBalance = "0.0"; +}); + +afterEach(() => { + cleanupPopup(); +}); + +describe("a popup that opened on Welcome", () => { + test.each(TABS)( + "after the first wallet is added from $tab, funds that arrive show on Home", + async (entry) => { + const { page } = await boot(undefined); + expect(page.pageErrors).toEqual([]); + await page.click("btn-welcome-add"); + await importOn(page, entry); + expect(page.visibleViews()).toEqual(["main"]); + expect(page.text("total-value")).toBe("0.0000 ETH"); + + chainBalance = "2.0"; + await page.tick(); + + expect(page.text("total-value")).toBe("2.0000 ETH"); + }, + ); + + test("a second wallet starts no second refresh", async () => { + const { page, refreshBalances } = await boot(undefined); + await page.click("btn-welcome-add"); + await importOn(page, TABS[1]); + await page.click("btn-add-wallet-bottom"); + await importOn(page, TABS[0]); + const calls = refreshBalances.mock.calls.length; + + await page.tick(); + + expect(refreshBalances).toHaveBeenCalledTimes(calls + 1); + }); + + test("a wallet added as the recovery screen goes up starts no refresh", async () => { + const { page, refreshBalances } = await boot(undefined); + // The stored record becomes unreadable during the address scan that + // follows the import, and a save finds it. + const { scanForAddresses } = require("../src/shared/balances"); + const { saveState } = require("../src/shared/state"); + scanForAddresses.mockImplementationOnce(async () => { + page.storage.write("autistmask", UNREADABLE); + await saveState().catch(() => {}); + return { addresses: [], nextIndex: 1 }; + }); + await page.click("btn-welcome-add"); + await importOn(page, TABS[0]); + expect(page.visibleViews()).toEqual(["state-recovery"]); + + await page.tick(); + + expect(refreshBalances).not.toHaveBeenCalled(); + }); +}); + +describe("a popup that opened with a wallet", () => { + test("adding a wallet starts no second refresh", async () => { + const { page, refreshBalances } = await boot(unversionedValidProfile()); + await page.click("btn-add-wallet-bottom"); + await importOn(page, TABS[1]); + const calls = refreshBalances.mock.calls.length; + + await page.tick(); + + expect(refreshBalances).toHaveBeenCalledTimes(calls + 1); + }); +});