// After "Add" on AddToken, AddressDetail lists the new token. Its line reads // `quantity unknown`, not 0.0000, until a balance refresh has read the token's // balance at every address, even if the popup closes and opens again // meanwhile. A token added on SettingsAddToken reads the same wherever it is // listed. A refresh that returns draws the balances on AddressDetail or // AddressToken again, so the fetched balance shows without the screen being // reopened (https://git.eeqj.de/sneak/AutistMask/issues/516). // // The tracked token's record carries `balanceNotFetched` until a refresh has // read the token's balance. Removing the token removes that record. A network // switch keeps each network's records apart. // // Driven through the real popup over a stored profile on Ethereum Mainnet, // with "Show tracked tokens with zero balance" on, as it is by default. const { bootPopup, cleanupPopup, unversionedValidProfile, } = require("./support/popupBoot"); // Tracked by nobody until a test adds it. const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; // A holding of a million NEW_TOKEN, as refreshBalances() stores it. const MILLION = { address: NEW_TOKEN, name: "New Token", symbol: "NEW", decimals: 18, balance: "1000000.0", holders: 3, }; afterEach(() => { cleanupPopup(); }); // The popup reopened onto AddressDetail for the one address. function bootOnAddress(extra) { return bootPopup( unversionedValidProfile({ currentView: "address", selectedWallet: 0, selectedAddress: 0, ...extra, }), ); } // Holds the next balance refresh open. The returned function lets it finish // with the address's ETH balance at 2 and `tokenBalances` as its token // entries, having read the balance of every token tracked when it started. // Given null, it finishes having read no token's balance, as when every // balanceOf fails, and leaves the token entries as they were. function holdNextRefresh(page) { const { refreshBalances } = require("../src/shared/balances"); let release; refreshBalances.mockImplementationOnce( async (wallets, rpcUrl, explorerUrl, trackedTokens) => { const read = trackedTokens.map((t) => t.address.toLowerCase()); const tokenBalances = await new Promise((resolve) => { release = resolve; }); wallets[0].addresses[0].balance = "2.0"; if (tokenBalances === null) return []; wallets[0].addresses[0].tokenBalances = tokenBalances; return read; }, ); return async (tokenBalances) => { release(tokenBalances); await page.settle(); }; } // Runs the ten-second refresh to the end, finishing it as holdNextRefresh() // does with `tokenBalances`. async function refreshOnce(page, tokenBalances) { const finish = holdNextRefresh(page); const ticking = page.tick(); await page.settle(); await finish(tokenBalances); await ticking; } // "+ Token", NEW_TOKEN typed in, and "Add", with the contract lookup // answering at once. async function addNewToken(page) { const { lookupTokenInfo } = require("../src/shared/balances"); lookupTokenInfo.mockResolvedValueOnce({ symbol: "NEW", decimals: 18, name: "New Token", }); await page.click("btn-add-token"); page.node("add-token-address").value = NEW_TOKEN; await page.click("btn-add-token-confirm"); } // Settings' "+ Add token", NEW_TOKEN typed in, and "Add", with the contract // lookup answering at once. It ends on SettingsAddToken. async function addOnSettingsAddToken(page) { const { lookupTokenInfo } = require("../src/shared/balances"); lookupTokenInfo.mockResolvedValueOnce({ symbol: "NEW", decimals: 18, name: "New Token", }); await page.click("btn-settings-add-token"); page.node("settings-addtoken-address").value = NEW_TOKEN; await page.click("btn-settings-addtoken-manual"); } // The "[x]" button Settings draws beside the first tracked token. The page // stub does not turn the markup Settings draws into elements. So the list // hands Settings a stand-in for that button each time it is drawn, and // `press()` presses the last one drawn. Call it before Settings opens. function firstRemoveButton(page) { const button = { press: null }; page.node("settings-tracked-tokens").querySelectorAll = () => [ { dataset: { idx: "0" }, addEventListener: (name, fn) => { button.press = fn; }, }, ]; return button; } // Picks `networkId` in the Settings network selector, as the user does. async function chooseNetwork(page, networkId) { const select = page.node("settings-network"); select.value = networkId; for (const fn of select.listeners.change) await fn(); await page.settle(); } // NEW_TOKEN's row in the balance list in element `id`, AddressDetail's unless // another is named, as markup. function newTokenRow(page, id = "address-balances") { return page .node(id) .innerHTML.split("
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'), ); } // AddressDetail for the one address opened from Home, as its `[info]` button // does. async function openAddress(page) { const { state } = require("../src/shared/state"); state.selectedWallet = 0; state.selectedAddress = 0; require("../src/popup/views/helpers").pushCurrentView(); require("../src/popup/views/addressDetail").show(); await page.settle(); } // AddressToken for `token` opened from AddressDetail, as tapping the token's // row there does. async function openToken(page, token) { const { state } = require("../src/shared/state"); state.selectedToken = token; require("../src/popup/views/helpers").pushCurrentView(); require("../src/popup/views/addressToken").show(); await page.settle(); } test("until the refresh Add started returns, the new token's line reads quantity unknown", async () => { const page = await bootOnAddress(); expect(page.pageErrors).toEqual([]); const finish = holdNextRefresh(page); await addNewToken(page); expect(page.visibleViews()).toEqual(["address"]); const row = newTokenRow(page); expect(row).toContain("quantity unknown"); expect(row).not.toContain("0.0000"); await finish([MILLION]); }); test("once that refresh returns, AddressDetail shows the fetched balance without being reopened", async () => { const page = await bootOnAddress(); const finish = holdNextRefresh(page); await addNewToken(page); // Stands for the transactions on the screen, which only show() replaces. page.node("tx-list").innerHTML = "transactions on the screen"; await finish([MILLION]); expect(page.visibleViews()).toEqual(["address"]); expect(newTokenRow(page)).toContain("1000000.0000"); // Only the balances are drawn again, not the transaction list. expect(page.node("tx-list").innerHTML).toBe("transactions on the screen"); }); test("a refresh already running when the token is added does not count as reading its balance", async () => { const page = await bootOnAddress(); const finishRunning = holdNextRefresh(page); const ticking = page.tick(); await page.settle(); await addNewToken(page); await finishRunning([]); await ticking; expect(newTokenRow(page)).toContain("quantity unknown"); // The next one reads the token's balance, which is zero. await refreshOnce(page, []); expect(newTokenRow(page)).toContain("0.0000"); }); test("closed and opened again before any refresh has read its balance, the new token's line still reads quantity unknown", async () => { const closed = await bootOnAddress(); holdNextRefresh(closed); await addNewToken(closed); cleanupPopup(); // The refresh this boot starts reads no token's balance: the boot's stub // of refreshBalances() reads none. const page = await bootPopup(undefined, { storage: closed.storage }); expect(page.visibleViews()).toEqual(["address"]); const row = newTokenRow(page); expect(row).toContain("quantity unknown"); expect(row).not.toContain("0.0000"); await refreshOnce(page, [MILLION]); expect(newTokenRow(page)).toContain("1000000.0000"); }); test("a refresh that could not read the new token's balance leaves its line quantity unknown", async () => { const page = await bootOnAddress(); const finish = holdNextRefresh(page); await addNewToken(page); await finish(null); // Drawn again: the ETH balance it fetched is shown. expect(page.node("address-balances").innerHTML).toContain("2.0000"); expect(newTokenRow(page)).toContain("quantity unknown"); }); test("AddressToken for the new token reads quantity unknown until the refresh returns, then the fetched balance", async () => { const page = await bootOnAddress(); const finish = holdNextRefresh(page); await addNewToken(page); await openToken(page, NEW_TOKEN.toLowerCase()); expect(page.visibleViews()).toEqual(["address-token"]); const before = page.node("address-token-balance").innerHTML; expect(before).toContain("quantity unknown"); expect(before).not.toContain("0.0000"); await finish([MILLION]); expect(page.node("address-token-balance").innerHTML).toContain( "1000000.0000", ); }); test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => { const page = await bootPopup(unversionedValidProfile()); expect(page.visibleViews()).toEqual(["main"]); const finish = holdNextRefresh(page); await page.click("btn-settings"); await addOnSettingsAddToken(page); await page.click("btn-settings-addtoken-back"); await page.click("btn-settings-back"); expect(page.visibleViews()).toEqual(["main"]); const onHome = newTokenRow(page, "wallet-list"); expect(onHome).toContain("quantity unknown"); expect(onHome).not.toContain("0.0000"); await openAddress(page); const onAddress = newTokenRow(page); expect(onAddress).toContain("quantity unknown"); expect(onAddress).not.toContain("0.0000"); await finish([MILLION]); expect(newTokenRow(page)).toContain("1000000.0000"); }); test("a refresh that returns while AddressToken is up draws its balance again", async () => { const page = await bootPopup( unversionedValidProfile({ currentView: "address-token", viewStack: ["main", "address"], selectedWallet: 0, selectedAddress: 0, selectedToken: "ETH", }), ); expect(page.visibleViews()).toEqual(["address-token"]); expect(page.node("address-token-balance").innerHTML).toContain("1.5000"); await refreshOnce(page, null); expect(page.node("address-token-balance").innerHTML).toContain("2.0000"); }); test("a token removed before any refresh has read its balance leaves nothing stored, and adding it again tracks it once", async () => { const page = await bootPopup(unversionedValidProfile()); const removeButton = firstRemoveButton(page); const stored = () => page.storage.read("autistmask"); await page.click("btn-settings"); await addOnSettingsAddToken(page); // Settings opened again from Home draws its list with the token in it. await page.click("btn-settings-addtoken-back"); await page.click("btn-settings-back"); await page.click("btn-settings"); await removeButton.press(); await page.settle(); expect(stored().trackedTokens).toEqual([]); // Nothing in the stored record names the token any more. expect(JSON.stringify(stored()).toLowerCase()).not.toContain( NEW_TOKEN.toLowerCase(), ); await addOnSettingsAddToken(page); // A second add of the same token is refused. page.node("settings-addtoken-address").value = NEW_TOKEN; await page.click("btn-settings-addtoken-manual"); const added = { address: NEW_TOKEN, symbol: "NEW", decimals: 18 }; expect(stored().trackedTokens).toEqual([ { ...added, name: "New Token", balanceNotFetched: true }, ]); await refreshOnce(page, []); expect(stored().trackedTokens).toEqual([{ ...added, name: "New Token" }]); }); test("a refresh on another network leaves the new token reading quantity unknown on its own", async () => { // Sepolia tracks a token at the same contract address, also just added. const sepoliaToken = { address: NEW_TOKEN, symbol: "SEP", name: "Sepolia Token", decimals: 18, }; const page = await bootOnAddress({ networkTrackedTokens: { sepolia: [{ ...sepoliaToken, balanceNotFetched: true }], }, }); const finishMainnet = holdNextRefresh(page); await addNewToken(page); await page.click("btn-settings"); await chooseNetwork(page, "sepolia"); // The refresh Add started returns after the switch and writes nothing. await finishMainnet([MILLION]); // Sepolia's refresh reads the balance of its own token. await refreshOnce(page, []); const stored = page.storage.read("autistmask"); expect(stored.trackedTokens).toEqual([sepoliaToken]); expect(stored.networkTrackedTokens.mainnet[0].balanceNotFetched).toBe(true); await chooseNetwork(page, "mainnet"); await page.click("btn-settings-back"); expect(page.visibleViews()).toEqual(["address"]); expect(newTokenRow(page)).toContain("quantity unknown"); await refreshOnce(page, [MILLION]); expect(newTokenRow(page)).toContain("1000000.0000"); });