// 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); }); // The popup follows the background's switch to Sepolia, but its own write in // that save fails. The user then picks Ethereum Mainnet in Settings, the // network the popup was on before: that switch is stored, and only then // flashed and sent to sites. test("a switch back in Settings after a follow whose write failed is stored", async () => { const page = await bootOnAddress(); await page.click("btn-settings"); const { set } = page.storage.local; const write = set.getMockImplementation(); // The background's write lands; the next, the follow's, fails. set.mockImplementationOnce(write).mockRejectedValueOnce( new Error("quota exceeded"), ); await backgroundSwitch(page, "sepolia"); const { state } = require("../src/shared/state"); expect(state.networkId).toBe("sepolia"); const select = page.node("settings-network"); select.value = "mainnet"; for (const fn of select.listeners.change) await fn(); await page.settle(); const stored = page.storage.read("autistmask"); expect(stored.networkId).toBe("mainnet"); expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl); expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]); expect(page.text("flash-msg")).toBe("Switched to Ethereum 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); }); // The user saves an endpoint URL in Settings on Ethereum Mainnet. Before the // URL is stored, the background saves a switch to Sepolia. Another write holds // the lock, so the popup's follow of that switch and the URL's save both wait // for the lock. Either may take it first. The URL is stored for neither // network. The flash message says that the network changed. test.each([ { button: "btn-save-rpc", field: "settings-rpc", first: "follow" }, { button: "btn-save-rpc", field: "settings-rpc", first: "save" }, { button: "btn-save-blockscout", field: "settings-blockscout", first: "follow", }, { button: "btn-save-blockscout", field: "settings-blockscout", first: "save", }, ])( "$button saves nothing when the network changes before the URL is stored ($first first)", async ({ button, field, first }) => { const page = await bootOnAddress(); await page.click("btn-settings"); // This answer passes both endpoint checks. The RPC check reads // Ethereum Mainnet's chain id from it. The Blockscout check reads `ok`. globalThis.fetch.mockResolvedValue({ ok: true, json: async () => ({ result: NETWORKS.mainnet.chainId }), }); const url = "https://own-node.example.invalid"; let releaseLock; const lockHeld = navigator.locks.request( "autistmask", () => new Promise((resolve) => { releaseLock = resolve; }), ); // The click handler runs until its save waits for the lock. const tapSave = async () => { page.node(field).value = url; const saving = page.node(button).listeners.click.map((fn) => fn()); await page.settle(); return saving; }; let saving; if (first === "save") saving = await tapSave(); await backgroundSwitch(page, "sepolia"); if (first === "follow") saving = await tapSave(); releaseLock(); await lockHeld; await Promise.all(saving); await page.settle(); const { state } = require("../src/shared/state"); expect(state.networkId).toBe("sepolia"); expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain( url, ); expect(page.text("flash-msg")).toBe( "The URL was not saved because the network changed.", ); }, ); // The popup's follow of the background's switch to Sepolia has set `state` to // Sepolia. The follow's write has not returned yet, so Settings still shows // Ethereum Mainnet. The user taps "Save" there. The URL is for Ethereum // Mainnet, so the save stores the URL for neither network. test.each([ { button: "btn-save-rpc", field: "settings-rpc" }, { button: "btn-save-blockscout", field: "settings-blockscout" }, ])( "$button saves nothing when tapped while the follow's write waits", async ({ button, field }) => { const page = await bootOnAddress(); await page.click("btn-settings"); // This answer passes both endpoint checks on Ethereum Mainnet. globalThis.fetch.mockResolvedValue({ ok: true, json: async () => ({ result: NETWORKS.mainnet.chainId }), }); const url = "https://own-node.example.invalid"; const { set } = page.storage.local; const write = set.getMockImplementation(); let releaseWrite; const writeHeld = new Promise((resolve) => { releaseWrite = resolve; }); // The background's write lands; the next, the follow's, waits. set.mockImplementationOnce(write).mockImplementationOnce( async (items) => { await writeHeld; await write(items); }, ); await backgroundSwitch(page, "sepolia"); const { state } = require("../src/shared/state"); expect(state.networkId).toBe("sepolia"); expect(page.value("settings-network")).toBe("mainnet"); page.node(field).value = url; const saving = page.node(button).listeners.click.map((fn) => fn()); await page.settle(); releaseWrite(); await Promise.all(saving); await page.settle(); expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain( url, ); expect(page.text("flash-msg")).toBe( "The URL was not saved because the network changed.", ); }, ); // The user taps the RPC "Save" on Ethereum Mainnet. While the check waits for // the node's answer, the popup follows the background's switch to Sepolia. The // node answers with Sepolia's chain id. So the check refuses the node for // Ethereum Mainnet, and nothing is stored. test("the RPC check compares the answer with the network at the tap", async () => { const page = await bootOnAddress(); await page.click("btn-settings"); let answer; globalThis.fetch.mockReturnValue( new Promise((resolve) => { answer = resolve; }), ); const url = "https://sepolia-node.example.invalid"; page.node("settings-rpc").value = url; const saving = page.node("btn-save-rpc").listeners.click.map((fn) => fn()); await page.settle(); await backgroundSwitch(page, "sepolia"); const { state } = require("../src/shared/state"); expect(state.networkId).toBe("sepolia"); answer({ json: async () => ({ result: NETWORKS.sepolia.chainId }) }); await Promise.all(saving); await page.settle(); expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url); expect(page.text("flash-msg")).toBe( "Wrong network: expected Ethereum Mainnet.", ); }); // The user taps the Blockscout "Save" on Ethereum Mainnet. While the check // waits for Blockscout's answer, the popup follows the background's switch to // Sepolia. The URL is for Ethereum Mainnet, so nothing is stored. test("the Blockscout save is for the network at the tap", async () => { const page = await bootOnAddress(); await page.click("btn-settings"); let answer; globalThis.fetch.mockReturnValue( new Promise((resolve) => { answer = resolve; }), ); const url = "https://own-explorer.example.invalid/api/v2"; page.node("settings-blockscout").value = url; const saving = page .node("btn-save-blockscout") .listeners.click.map((fn) => fn()); await page.settle(); await backgroundSwitch(page, "sepolia"); expect(page.value("settings-network")).toBe("sepolia"); answer({ ok: true }); await Promise.all(saving); await page.settle(); expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url); expect(page.text("flash-msg")).toBe( "The URL was not saved because the network changed.", ); }); // Settings is open when the network changes to Sepolia. The popup follows the // background's switch, or the user picks Sepolia in the network selector. Each // "Save" after the switch stores its URL for Sepolia. test.each([ ["a follow", (page) => backgroundSwitch(page, "sepolia")], [ "the network selector", async (page) => { const select = page.node("settings-network"); select.value = "sepolia"; for (const fn of select.listeners.change) await fn(); await page.settle(); }, ], ])( "after a switch to Sepolia by %s, Settings saves each URL for Sepolia", async (_how, switchToSepolia) => { const page = await bootOnAddress(); await page.click("btn-settings"); await switchToSepolia(page); // This answer passes both endpoint checks on Sepolia. The RPC check // reads Sepolia's chain id from it. The Blockscout check reads `ok`. globalThis.fetch.mockResolvedValue({ ok: true, json: async () => ({ result: NETWORKS.sepolia.chainId }), }); const rpcUrl = "https://own-node.example.invalid"; const blockscoutUrl = "https://own-explorer.example.invalid/api/v2"; page.node("settings-rpc").value = rpcUrl; await page.click("btn-save-rpc"); expect(page.text("flash-msg")).toBe("Saved."); page.node("settings-blockscout").value = blockscoutUrl; await page.click("btn-save-blockscout"); expect(page.text("flash-msg")).toBe("Saved."); const stored = page.storage.read("autistmask"); expect(stored.networkId).toBe("sepolia"); expect(stored.rpcUrl).toBe(rpcUrl); expect(stored.blockscoutUrl).toBe(blockscoutUrl); }, ); // The user picks Sepolia in Settings, but the switch's write fails. The page is // then on Sepolia, while Settings was last drawn for Ethereum Mainnet. A "Save" // after the failed switch stores nothing for either network. test.each([ { button: "btn-save-rpc", field: "settings-rpc" }, { button: "btn-save-blockscout", field: "settings-blockscout" }, ])( "$button saves nothing after a switch in Settings whose write failed", async ({ button, field }) => { const page = await bootOnAddress(); await page.click("btn-settings"); page.storage.local.set.mockRejectedValueOnce( new Error("quota exceeded"), ); const select = page.node("settings-network"); select.value = "sepolia"; for (const fn of select.listeners.change) { await expect(fn()).rejects.toThrow("quota exceeded"); } await page.settle(); const { state } = require("../src/shared/state"); expect(state.networkId).toBe("sepolia"); // This answer passes both endpoint checks on Ethereum Mainnet. globalThis.fetch.mockResolvedValue({ ok: true, json: async () => ({ result: NETWORKS.mainnet.chainId }), }); const url = "https://own-node.example.invalid"; page.node(field).value = url; await page.click(button); expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain( url, ); expect(page.text("flash-msg")).toBe( "The URL was not saved because the network changed.", ); }, ); // 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); });