// Back after reopening the popup (#268). // // A reopened popup renders the wallet list and the one view it restores // onto; every other view is still the blank static template from // index.html. goBack() used to only unhide its target, so Back landed on // that blank template for any view the popup had not rendered in this page // load. These tests drive the real goBack() with the real router wired to // recording view modules, so what is asserted is which view render ran — // the thing that was missing. // // The rendering itself is asserted against the real popup in a real // browser by tests/e2e/run.js; here the DOM is a stub, because goBack() // only needs showView() to work. const els = new Map(); function fakeEl() { return { textContent: "", innerHTML: "", classList: { toggle() {}, add() {}, remove() {}, contains: () => false, }, remove() {}, }; } globalThis.document = { getElementById(id) { if (!els.has(id)) els.set(id, fakeEl()); return els.get(id); }, }; // helpers.js pulls in state.js, which reads chrome.storage.local at load. globalThis.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, }; const { showView, goBack, setBackRenderer, pushCurrentView, } = require("../src/popup/views/helpers"); const { makeBackRenderer } = require("../src/popup/viewRouter"); const { state } = require("../src/shared/state"); const ADDRESS = "0x1111111111111111111111111111111111111111"; const TOKEN = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; let calls; // Stand-ins for the view modules. Each records itself and then shows its // view, which is what every real view render ends with — so the assertions // can tell "rendered and shown" apart from "merely unhidden". function recorder(name, view) { return () => { calls.push(name); showView(view); }; } function makeViews() { return { main: { show: recorder("main", "main") }, addressDetail: { show: recorder("addressDetail", "address") }, addressToken: { show: recorder("addressToken", "address-token") }, receive: { show: recorder("receive", "receive") }, settings: { show: recorder("settings", "settings") }, settingsAddToken: { show: recorder("settingsAddToken", "settings-addtoken"), }, confirmTx: { restore: recorder("confirmTx", "confirm-tx") }, transactionDetail: { render: recorder("transactionDetail", "transaction"), }, txStatus: { restoreWait: () => { calls.push("waitTx"); showView("wait-tx"); return true; }, renderSuccess: recorder("successTx", "success-tx"), renderError: recorder("errorTx", "error-tx"), }, }; } // The popup as it stands just after a reopen: one wallet with one address, // the view the popup restored onto, and the stack behind it. function reopenedOn(view, stack, extra) { calls = []; state.wallets = [ { name: "Wallet 1", addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }], }, ]; state.selectedWallet = 0; state.selectedAddress = 0; state.selectedToken = null; state.viewData = null; state.currentView = view; state.viewStack = stack.slice(); Object.assign(state, extra || {}); setBackRenderer(makeBackRenderer(state, makeViews())); } // The reproduction from the issue, step for step. describe("Back onto a view the reopened popup never rendered", () => { test("Back from settings renders the address detail underneath", () => { reopenedOn("settings", ["main", "address"]); goBack(); expect(calls).toEqual(["addressDetail"]); expect(state.currentView).toBe("address"); expect(state.viewStack).toEqual(["main"]); }); test("Back onto the token detail renders it", () => { reopenedOn("settings", ["main", "address", "address-token"], { selectedToken: TOKEN, }); goBack(); expect(calls).toEqual(["addressToken"]); expect(state.currentView).toBe("address-token"); }); test("Back onto Receive renders it", () => { reopenedOn("settings", ["main", "address", "receive"]); goBack(); expect(calls).toEqual(["receive"]); expect(state.currentView).toBe("receive"); }); test("Back onto the transaction detail renders it", () => { reopenedOn("settings", ["main", "transaction"], { viewData: { tx: { hash: "0xdead" } }, }); goBack(); expect(calls).toEqual(["transactionDetail"]); expect(state.currentView).toBe("transaction"); }); test("Back onto the transaction confirmation restores it", () => { reopenedOn("settings", ["main", "confirm-tx"], { viewData: { pendingTx: { to: ADDRESS, amount: "1" } }, }); goBack(); expect(calls).toEqual(["confirmTx"]); expect(state.currentView).toBe("confirm-tx"); }); test("Back onto Home renders the wallet list", () => { reopenedOn("settings", ["main"]); goBack(); expect(calls).toEqual(["main"]); expect(state.currentView).toBe("main"); }); test("Back with an empty stack renders Home", () => { reopenedOn("settings", []); goBack(); expect(calls).toEqual(["main"]); expect(state.currentView).toBe("main"); }); }); // The guards are restoreView()'s, so a popped view whose backing data is // gone lands on Home rather than on an empty template. describe("Back onto a view whose backing data is gone", () => { test("the token detail with no token selected falls back to Home", () => { reopenedOn("settings", ["main", "address-token"]); goBack(); expect(calls).toEqual(["main"]); expect(state.currentView).toBe("main"); }); test("the transaction detail with no transaction falls back to Home", () => { reopenedOn("settings", ["main", "transaction"]); goBack(); expect(calls).toEqual(["main"]); expect(state.currentView).toBe("main"); }); test("the confirmation with no pending transaction falls back to Home", () => { reopenedOn("settings", ["main", "confirm-tx"]); goBack(); expect(calls).toEqual(["main"]); expect(state.currentView).toBe("main"); }); test("an address view with no address selected falls back to Home", () => { reopenedOn("settings", ["main", "receive"], { selectedAddress: null, }); goBack(); expect(calls).toEqual(["main"]); expect(state.currentView).toBe("main"); }); test("a wait that can no longer be resumed falls back to Home", () => { reopenedOn("settings", ["main", "wait-tx"]); const views = makeViews(); views.txStatus.restoreWait = () => false; setBackRenderer(makeBackRenderer(state, views)); goBack(); expect(calls).toEqual(["main"]); expect(state.currentView).toBe("main"); }); }); // Forward navigation renders as it goes. Re-rendering a second time would // re-fetch and clobber whatever the view has in flight, so the Back path is // the only place this happens — and it declines any view the popup does not // render from persisted state, since the restored stack is filtered against // that same set and such a view can only have been rendered in this page // load. describe("what the Back path leaves alone", () => { test("forward navigation renders nothing by itself", () => { reopenedOn("address", ["main"]); pushCurrentView(); showView("send"); expect(calls).toEqual([]); expect(state.viewStack).toEqual(["main", "address"]); }); test("Back onto a live-session view only unhides it", () => { reopenedOn("confirm-tx", ["main", "address", "send"]); goBack(); expect(calls).toEqual([]); expect(state.currentView).toBe("send"); }); test("Back renders its target exactly once", () => { reopenedOn("settings", ["main", "address"]); goBack(); expect(calls.filter((c) => c === "addressDetail")).toHaveLength(1); }); });