// Tracked tokens are kept per network, and the two add-token screens offer the // bundled token list, which holds Ethereum Mainnet contracts, on Ethereum // Mainnet only (https://git.eeqj.de/sneak/AutistMask/issues/523), including on // the screen "Back" lands on after a switch made from the Settings gear and for // a contract lookup that answers after one. Driven through the real popup over // a stored profile; the same switch made by the background is in // tests/coldWorkerChainSwitch.test.js. const { bootPopup, cleanupPopup, unversionedValidProfile, ADDRESS, TOKEN_ADDRESS, } = require("./support/popupBoot"); const { NETWORKS } = require("../src/shared/networks"); const SEPOLIA_TOKEN = { address: TOKEN_ADDRESS, symbol: "TST", decimals: 18, name: "Test Token", }; const MAINNET_TOKEN = { address: "0x6B175474E89094C44Da98b954EedeAC495271d0F", symbol: "DAI", decimals: 18, name: "Dai Stablecoin", }; // Tracked on neither network. const NEW_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; const NETWORK_CHANGED_FLASH = "The active network changed before the lookup finished, so the token was" + " not added."; afterEach(() => { cleanupPopup(); }); // The popup reopened onto SettingsAddToken, on `networkId`. function bootOnAddToken(networkId, extra) { return bootPopup( unversionedValidProfile({ networkId, currentView: "settings-addtoken", ...extra, }), ); } // The popup reopened onto AddressDetail on Ethereum Mainnet, with DAI tracked // there and TST tracked on Sepolia. function bootOnAddress() { return bootPopup( unversionedValidProfile({ currentView: "address", selectedWallet: 0, selectedAddress: 0, trackedTokens: [MAINNET_TOKEN], networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] }, }), ); } // The popup reopened onto AddressToken for `token`, with AddressDetail beneath // it, over the same tracked tokens as bootOnAddress(). function bootOnToken(token) { return bootPopup( unversionedValidProfile({ currentView: "address-token", viewStack: ["main", "address"], selectedWallet: 0, selectedAddress: 0, selectedToken: token, trackedTokens: [MAINNET_TOKEN], networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] }, }), ); } // AddressToken for `token` opened from AddressDetail, as tapping the token's // row there does. async function openToken(boot, token) { const { state } = require("../src/shared/state"); state.selectedToken = token; require("../src/popup/views/helpers").pushCurrentView(); require("../src/popup/views/addressToken").show(); await boot.settle(); } // Presses `addButton` with a contract lookup that answers only after the // Settings gear and a switch to `networkId`. async function addAcrossSwitch(boot, addButton, networkId) { const { lookupTokenInfo } = require("../src/shared/balances"); let answer; lookupTokenInfo.mockImplementationOnce( () => new Promise((resolve) => { answer = resolve; }), ); const adding = boot.click(addButton); await boot.click("btn-settings"); await chooseNetwork(boot, networkId); answer({ symbol: "NEW", decimals: 18, name: "New Token" }); await adding; } // Picks `networkId` in the Settings network selector, as the user does. async function chooseNetwork(boot, networkId) { const select = boot.node("settings-network"); select.value = networkId; for (const fn of select.listeners.change) await fn(); await boot.settle(); } // The Settings gear, a switch to Sepolia, and Back. async function gearSwitchBack(boot) { await boot.click("btn-settings"); expect(boot.visibleViews()).toEqual(["settings"]); await chooseNetwork(boot, "sepolia"); await boot.click("btn-settings-back"); } describe.each([ ["mainnet", false], ["sepolia", true], ])("on %s", (networkId, hidden) => { test(`the common tokens are hidden: ${hidden}`, async () => { const boot = await bootOnAddToken(networkId); expect(boot.pageErrors).toEqual([]); expect(boot.visibleViews()).toEqual(["settings-addtoken"]); expect(boot.hidden("settings-addtoken-common")).toBe(hidden); require("../src/popup/views/addToken").show(); expect(boot.visibleViews()).toEqual(["add-token"]); expect(boot.hidden("add-token-common")).toBe(hidden); }); }); test("a token added by contract address on Sepolia is tracked on Sepolia only", async () => { const boot = await bootOnAddToken("sepolia", { networkTrackedTokens: { mainnet: [MAINNET_TOKEN] }, }); const stored = () => boot.storage.read("autistmask"); const { lookupTokenInfo } = require("../src/shared/balances"); lookupTokenInfo.mockResolvedValueOnce({ symbol: SEPOLIA_TOKEN.symbol, decimals: SEPOLIA_TOKEN.decimals, name: SEPOLIA_TOKEN.name, }); boot.node("settings-addtoken-address").value = TOKEN_ADDRESS; await boot.click("btn-settings-addtoken-manual"); expect(lookupTokenInfo).toHaveBeenCalledWith( TOKEN_ADDRESS, expect.any(String), "sepolia", expect.anything(), ); expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); await chooseNetwork(boot, "mainnet"); expect(stored().networkId).toBe("mainnet"); expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]); const onMainnet = boot.node("settings-tracked-tokens").innerHTML; expect(onMainnet).toContain("DAI"); expect(onMainnet).not.toContain("TST"); await chooseNetwork(boot, "sepolia"); expect(stored().networkId).toBe("sepolia"); expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); const onSepolia = boot.node("settings-tracked-tokens").innerHTML; expect(onSepolia).toContain("TST"); expect(onSepolia).not.toContain("DAI"); }); describe("Back after a switch from the Settings gear", () => { test("SettingsAddToken no longer offers the common tokens", async () => { const boot = await bootOnAddToken("mainnet"); expect(boot.hidden("settings-addtoken-common")).toBe(false); await gearSwitchBack(boot); expect(boot.visibleViews()).toEqual(["settings-addtoken"]); expect(boot.hidden("settings-addtoken-common")).toBe(true); }); test("AddressDetail lists the new network's tracked tokens", async () => { const boot = await bootOnAddress(); expect(boot.node("address-balances").innerHTML).toContain("DAI"); await gearSwitchBack(boot); expect(boot.visibleViews()).toEqual(["address"]); const balances = boot.node("address-balances").innerHTML; expect(balances).toContain("TST"); expect(balances).not.toContain("DAI"); }); // Neither can be rendered from saved state, so neither is landed on. test.each([ ["btn-add-token", "add-token"], ["btn-send", "send"], ])("from over %s, it lands on AddressDetail", async (button, view) => { const boot = await bootOnAddress(); await boot.click(button); expect(boot.visibleViews()).toEqual([view]); await gearSwitchBack(boot); expect(boot.visibleViews()).toEqual(["address"]); expect(boot.node("address-balances").innerHTML).not.toContain("DAI"); }); // DAI was picked on Ethereum Mainnet, where alone it is tracked. test.each([ [ "opened from AddressDetail", async () => { const boot = await bootOnAddress(); await openToken(boot, MAINNET_TOKEN.address); return boot; }, ], ["in a reopened popup", () => bootOnToken(MAINNET_TOKEN.address)], ])( "from AddressToken for DAI %s, it lands on AddressDetail", async (_how, open) => { const boot = await open(); expect(boot.visibleViews()).toEqual(["address-token"]); expect(boot.text("address-token-title")).toContain("DAI"); await gearSwitchBack(boot); expect(boot.visibleViews()).toEqual(["address"]); const balances = boot.node("address-balances").innerHTML; expect(balances).toContain("TST"); expect(balances).not.toContain("DAI"); }, ); // The stack is saved cut with the switch, so a popup closed straight after // it and reopened onto Settings lands where this one would. Loading a // saved stack cuts it at AddToken and Send as well, but not at // AddressToken. test.each([ [ "AddressToken for DAI", (boot) => openToken(boot, MAINNET_TOKEN.address), ], ["AddToken", (boot) => boot.click("btn-add-token")], ["Send", (boot) => boot.click("btn-send")], ])( "from %s, a popup reopened after the switch lands on AddressDetail", async (_view, open) => { const boot = await bootOnAddress(); await open(boot); await boot.click("btn-settings"); await chooseNetwork(boot, "sepolia"); const reopened = await bootPopup(boot.storage.read("autistmask")); expect(reopened.visibleViews()).toEqual(["settings"]); await reopened.click("btn-settings-back"); expect(reopened.visibleViews()).toEqual(["address"]); const balances = reopened.node("address-balances").innerHTML; expect(balances).toContain("TST"); expect(balances).not.toContain("DAI"); }, ); test("AddressToken for the native token is drawn again for the new network", async () => { const boot = await bootOnToken("ETH"); expect(boot.visibleViews()).toEqual(["address-token"]); await gearSwitchBack(boot); expect(boot.visibleViews()).toEqual(["address-token"]); expect(boot.text("address-token-title")).toContain("SepoliaETH"); }); // A popup reopened onto ConfirmTx has no Send beneath it, so "Back" draws // ConfirmTx again from the saved transaction. It is drawn for the network // the transaction was reviewed on, and Sign & Send refuses it on the new // one. test("ConfirmTx reviewed on Sepolia is not sent on Ethereum Mainnet", async () => { const boot = await bootPopup( unversionedValidProfile({ networkId: "sepolia", currentView: "confirm-tx", selectedWallet: 0, selectedAddress: 0, viewData: { pendingTx: { token: "ETH", from: ADDRESS, to: ADDRESS, amount: "1", chainId: NETWORKS.sepolia.chainId, }, }, }), ); expect(boot.visibleViews()).toEqual(["confirm-tx"]); await boot.click("btn-settings"); await chooseNetwork(boot, "mainnet"); globalThis.fetch.mockClear(); await boot.click("btn-settings-back"); expect(boot.visibleViews()).toEqual(["confirm-tx"]); // Drawn again: its Etherscan label check asks Sepolia's explorer. expect(globalThis.fetch.mock.calls.map((call) => call[0])).toEqual([ NETWORKS.sepolia.explorerUrl + "/address/" + ADDRESS, ]); expect(boot.text("confirm-type")).toBe("Native SepoliaETH transfer"); boot.node("confirm-tx-password").value = "any password"; await boot.click("btn-confirm-send"); expect(boot.visibleViews()).toEqual(["confirm-tx"]); expect( boot.node("confirm-network-changed-error").style.visibility, ).toBe("visible"); // Refused before the password is even tried. expect(boot.text("confirm-tx-password-error")).toBe(""); }); test("without a wallet, it lands on Welcome", async () => { const boot = await bootPopup(undefined); expect(boot.visibleViews()).toEqual(["welcome"]); await gearSwitchBack(boot); expect(boot.visibleViews()).toEqual(["welcome"]); }); test("a later Back onto Settings keeps what was typed there", async () => { const boot = await bootOnAddress(); await boot.click("btn-settings"); await chooseNetwork(boot, "sepolia"); boot.node("settings-rpc").value = "https://typed.example"; await boot.click("btn-settings-add-token"); expect(boot.visibleViews()).toEqual(["settings-addtoken"]); await boot.click("btn-settings-addtoken-back"); expect(boot.visibleViews()).toEqual(["settings"]); expect(boot.value("settings-rpc")).toBe("https://typed.example"); }); }); describe("a contract lookup that answers after a switch adds nothing", () => { test("from SettingsAddToken", async () => { const boot = await bootOnAddToken("sepolia", { networkTrackedTokens: { mainnet: [MAINNET_TOKEN] }, }); const stored = () => boot.storage.read("autistmask"); boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS; await addAcrossSwitch(boot, "btn-settings-addtoken-manual", "mainnet"); expect(boot.visibleViews()).toEqual(["settings"]); expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH); expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]); expect(stored().networkTrackedTokens.sepolia).toEqual([]); }); test("from AddToken", async () => { const boot = await bootOnAddress(); const stored = () => boot.storage.read("autistmask"); await boot.click("btn-add-token"); boot.node("add-token-address").value = NEW_TOKEN_ADDRESS; await addAcrossSwitch(boot, "btn-add-token-confirm", "sepolia"); expect(boot.visibleViews()).toEqual(["settings"]); expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH); expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]); }); });