fix: render the view "Back" lands on after the popup is reopened (closes #268)
All checks were successful
check / check (push) Successful in 1m27s
All checks were successful
check / check (push) Successful in 1m27s
This commit was merged in pull request #272.
This commit is contained in:
329
tests/backNavigation.test.js
Normal file
329
tests/backNavigation.test.js
Normal file
@@ -0,0 +1,329 @@
|
||||
// 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,
|
||||
markViewRendered,
|
||||
resetRenderedViews,
|
||||
} = 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.
|
||||
//
|
||||
// A reopen is a fresh page load, so the record of what has been rendered
|
||||
// starts empty — that emptiness is what makes the Back path render at all.
|
||||
// Returns the view modules so a test can drive forward navigation through
|
||||
// the same recorders the router renders through.
|
||||
function reopenedOn(view, stack, extra) {
|
||||
calls = [];
|
||||
resetRenderedViews();
|
||||
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 || {});
|
||||
// Restoring onto a view renders it, so the reopened popup has that one
|
||||
// view on the page and nothing else.
|
||||
markViewRendered(view);
|
||||
const views = makeViews();
|
||||
setBackRenderer(makeBackRenderer(state, views));
|
||||
return views;
|
||||
}
|
||||
|
||||
// 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 the success screen renders it", () => {
|
||||
reopenedOn("settings", ["main", "success-tx"], {
|
||||
viewData: { hash: "0xdead" },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["successTx"]);
|
||||
expect(state.currentView).toBe("success-tx");
|
||||
});
|
||||
|
||||
test("Back onto the failure screen renders it", () => {
|
||||
reopenedOn("settings", ["main", "error-tx"], {
|
||||
viewData: { message: "execution reverted" },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["errorTx"]);
|
||||
expect(state.currentView).toBe("error-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("the success screen with no transaction hash falls back to Home", () => {
|
||||
reopenedOn("settings", ["main", "success-tx"]);
|
||||
goBack();
|
||||
expect(calls).toEqual(["main"]);
|
||||
expect(state.currentView).toBe("main");
|
||||
});
|
||||
|
||||
test("the failure screen with no message falls back to Home", () => {
|
||||
reopenedOn("settings", ["main", "error-tx"]);
|
||||
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, and a second render would re-fetch
|
||||
// and clobber whatever the view holds — an unsaved edit, a request in
|
||||
// flight. So the Back path renders only a view this page load has never
|
||||
// rendered, and merely unhides every other one: the views it does not
|
||||
// render from persisted state, and the views already on the page.
|
||||
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);
|
||||
});
|
||||
|
||||
test("Back onto a view this page load already rendered only unhides it", () => {
|
||||
const views = reopenedOn("main", []);
|
||||
pushCurrentView();
|
||||
views.addressDetail.show();
|
||||
pushCurrentView();
|
||||
views.settings.show();
|
||||
calls = [];
|
||||
goBack();
|
||||
expect(calls).toEqual([]);
|
||||
expect(state.currentView).toBe("address");
|
||||
});
|
||||
|
||||
// The unit mirror of the regression the browser suite pins: Settings
|
||||
// reassigns its fields from persisted state on every render, so a
|
||||
// re-render on the way back discards an edit the user has not saved.
|
||||
test("Back onto Settings visited earlier in this page load does not re-render it", () => {
|
||||
const views = reopenedOn("main", []);
|
||||
pushCurrentView();
|
||||
views.settings.show();
|
||||
pushCurrentView();
|
||||
views.settingsAddToken.show();
|
||||
calls = [];
|
||||
goBack();
|
||||
expect(calls).toEqual([]);
|
||||
expect(state.currentView).toBe("settings");
|
||||
});
|
||||
|
||||
// Home is the deliberate exception, unchanged from the popup's
|
||||
// behaviour before the router existed: it re-renders on every Back so
|
||||
// the wallet list reflects what changed while the user was away.
|
||||
test("Back onto Home renders it again even when it is already on the page", () => {
|
||||
const views = reopenedOn("main", []);
|
||||
pushCurrentView();
|
||||
views.addressDetail.show();
|
||||
calls = [];
|
||||
goBack();
|
||||
expect(calls).toEqual(["main"]);
|
||||
expect(state.currentView).toBe("main");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user