// An open popup follows a network switch the background saves once the user // approves a site's request: it takes the new network from the stored record // and draws the screen it is on for it, as a switch in Settings does // (https://git.eeqj.de/sneak/AutistMask/issues/650). Driven through the real // popup over a stored profile on Ethereum Mainnet. The background's write is // the storage stub's set, which the stub reports to the page as the browser // does. const { bootPopup, cleanupPopup, unversionedValidProfile, TOKEN_ADDRESS, } = require("./support/popupBoot"); const { makeStorageStub } = require("./support/storageStub"); const { applyChainSwitchFields, copyChainSwitchFields, } = require("../src/shared/chainSwitchFields"); const { NETWORKS } = require("../src/shared/networks"); const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema"); afterEach(() => { cleanupPopup(); }); 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", }; // A profile on Ethereum Mainnet that reopens the popup onto AddressDetail, // with DAI tracked there and TST tracked on Sepolia. function profileOnAddress() { return unversionedValidProfile({ currentView: "address", viewStack: ["main"], selectedWallet: 0, selectedAddress: 0, trackedTokens: [MAINNET_TOKEN], networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] }, }); } // The popup reopened onto it. function bootOnAddress() { return bootPopup(profileOnAddress()); } // The background saves a switch to `networkId`, as its updateState() does // once the user approves a site's request. async function backgroundSwitch(page, networkId) { const record = page.storage.read("autistmask"); applyChainSwitchFields(record, networkId); await page.storage.local.set({ autistmask: record }); await page.settle(); await page.settle(); } test("the popup open on AddressDetail shows the new network and its balances", async () => { const page = await bootOnAddress(); expect(page.pageErrors).toEqual([]); expect(page.visibleViews()).toEqual(["address"]); expect(page.node("address-balances").innerHTML).toContain("DAI"); expect(page.node("debug-banner")).toBeNull(); await backgroundSwitch(page, "sepolia"); const { state } = require("../src/shared/state"); expect(state.networkId).toBe("sepolia"); expect(state.rpcUrl).toBe(NETWORKS.sepolia.defaultRpcUrl); expect(page.visibleViews()).toEqual(["address"]); const balances = page.node("address-balances").innerHTML; expect(balances).toContain("TST"); expect(balances).toContain("SepoliaETH"); expect(balances).not.toContain("DAI"); expect(page.text("debug-banner")).toBe("[TESTNET]"); // Its next refresh runs on Sepolia, and what it fetched is stored. await page.tick(); const { refreshBalances } = require("../src/shared/balances"); expect(refreshBalances.mock.lastCall[4]).toBe("sepolia"); const stored = page.storage.read("autistmask"); expect(stored.networkId).toBe("sepolia"); expect(stored.lastBalanceRefresh).toBeGreaterThan(0); }); test("the popup open on Settings shows the new network, and Back draws the screen beneath for it", async () => { const page = await bootOnAddress(); await page.click("btn-settings"); expect(page.visibleViews()).toEqual(["settings"]); await backgroundSwitch(page, "sepolia"); expect(page.visibleViews()).toEqual(["settings"]); expect(page.value("settings-network")).toBe("sepolia"); expect(page.value("settings-rpc")).toBe(NETWORKS.sepolia.defaultRpcUrl); expect(page.node("settings-tracked-tokens").innerHTML).toContain("TST"); expect(page.text("debug-banner")).toBe("[TESTNET]"); await page.click("btn-settings-back"); expect(page.visibleViews()).toEqual(["address"]); expect(page.node("address-balances").innerHTML).not.toContain("DAI"); }); // Neither can be drawn again for the new network: Send cannot be rendered from // saved state, and DAI was picked on Ethereum Mainnet, where alone it is // tracked. A reopened popup goes to Home in their place. test.each([ ["Send", (page) => page.click("btn-send")], [ "AddressToken for DAI", async (page) => { const { state } = require("../src/shared/state"); state.selectedToken = MAINNET_TOKEN.address; require("../src/popup/views/helpers").pushCurrentView(); require("../src/popup/views/addressToken").show(); await page.settle(); }, ], ])("the popup open on %s goes to Home", async (_view, open) => { const page = await bootOnAddress(); await open(page); expect(page.visibleViews()).not.toEqual(["address"]); await backgroundSwitch(page, "sepolia"); expect(page.visibleViews()).toEqual(["main"]); expect(page.text("total-value")).toBe("0.0000 SepoliaETH"); }); // The report of the switch's own save names the network the page already has. test("a switch in Settings is not taken a second time when its save is reported", async () => { const page = await bootOnAddress(); await page.click("btn-settings"); const { get, set } = page.storage.local; const reads = get.mock.calls.length; const writes = set.mock.calls.length; const select = page.node("settings-network"); select.value = "sepolia"; for (const fn of select.listeners.change) await fn(); await page.settle(); await page.settle(); expect(get).toHaveBeenCalledTimes(reads + 1); expect(set).toHaveBeenCalledTimes(writes + 1); expect(page.text("flash-msg")).toBe("Switched to Sepolia Testnet."); }); // The background saves the switch once the popup has read the stored record // and before it listens for changes to it, so the browser tells the page // nothing of it. Nor is any later write reported here: Chrome reports none // that leaves the record as it was, such as the popup's own save at start-up. test("a switch saved while the popup opens, before it listens for one, is followed", async () => { const storage = makeStorageStub({ autistmask: profileOnAddress() }); storage.onChanged = { addListener: () => {} }; const read = storage.local.get.getMockImplementation(); storage.local.get.mockImplementationOnce(async (key) => { const loaded = await read(key); const record = storage.read("autistmask"); applyChainSwitchFields(record, "sepolia"); storage.write("autistmask", record); return loaded; }); const page = await bootPopup(undefined, { storage }); await page.settle(); const { state } = require("../src/shared/state"); expect(page.pageErrors).toEqual([]); expect(state.networkId).toBe("sepolia"); expect(page.visibleViews()).toEqual(["address"]); expect(page.node("address-balances").innerHTML).toContain("SepoliaETH"); expect(page.text("debug-banner")).toBe("[TESTNET]"); }); // While the popup's follow of the background's switch to Sepolia waits for the // lock, the user picks Sepolia in Settings and then Ethereum Mainnet again. The // last pick is the network stored, and the last one sites are told of. test("a switch in Settings made while a follow waits for the lock is not overwritten by it", async () => { const page = await bootOnAddress(); await page.click("btn-settings"); // Another write holds the lock, so every save of the page waits for it. let releaseLock; const lockHeld = navigator.locks.request( "autistmask", () => new Promise((resolve) => { releaseLock = resolve; }), ); await backgroundSwitch(page, "sepolia"); const select = page.node("settings-network"); const picks = []; for (const networkId of ["sepolia", "mainnet"]) { select.value = networkId; for (const fn of select.listeners.change) picks.push(fn()); } releaseLock(); await lockHeld; await Promise.all(picks); await page.settle(); const { state } = require("../src/shared/state"); expect(state.networkId).toBe("mainnet"); expect(page.storage.read("autistmask").networkId).toBe("mainnet"); expect(page.value("settings-network")).toBe("mainnet"); const told = globalThis.chrome.runtime.sendMessage.mock.calls .map(([message]) => message) .filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED"); expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId); }); // Such as one a newer build wrote, which this build cannot read. Nothing reads // or writes the stored record for it: the popup's next save finds it, as // before. test("a record that names no network is not followed", async () => { const page = await bootOnAddress(); const { get, set } = page.storage.local; const reads = get.mock.calls.length; await set({ autistmask: { schemaVersion: STATE_SCHEMA_VERSION + 1, wallets: [{ type: "hd", name: "Main", addresses: [] }], }, }); await page.settle(); expect(get).toHaveBeenCalledTimes(reads); expect(page.visibleViews()).toEqual(["address"]); }); // A record that takes the switch from the switched record ends up as that // record, balances the background fetched since included. test("copyChainSwitchFields() takes every field applyChainSwitchFields() sets", () => { const before = unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN], networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] }, networkEndpoints: { sepolia: { rpcUrl: "http://127.0.0.1:8545", blockscoutUrl: "http://127.0.0.1:4000/api/v2", }, }, lastBalanceRefresh: 1, tokenHolderCache: { [MAINNET_TOKEN.address]: 5000 }, fraudContracts: ["0x1111111111111111111111111111111111111111"], }); const switched = structuredClone(before); applyChainSwitchFields(switched, "sepolia"); switched.wallets[0].addresses[0].balance = "2.0"; const follower = structuredClone(before); copyChainSwitchFields(follower, switched); expect(follower).toEqual(switched); });