Sign & Send wrote its refusals, a network changed since review and a gas limit or fee over its bound, into the errors box above the password field. That box reserves one empty line, so a refusal grew it and pushed the password field and Sign & Send down. Each refusal now has its own box with its sentence fixed in index.html, holding its space from the first paint and only made visible, as #558 did for the balance messages. One sentence is true of all three bounds and quotes no number; the full reason is logged. A Chrome test raises the stub node's fee only at signing and checks nothing moved; a unit test refuses a gas limit over its bound into the same box. Model: opus-5-5
373 lines
14 KiB
JavaScript
373 lines
14 KiB
JavaScript
// 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]);
|
|
});
|
|
});
|