Out of the box the wallet sent the user's addresses to Blockscout for history and token lookups, and each recipient to Etherscan for its label. Two settings, "Use Blockscout" and "Check recipients on Etherscan", now gate those requests where they are made. Both are off in a new profile and offered unticked on Welcome and in Settings. While one is off, the screens that relied on it say so in a plain line naming the setting. README and the user guide say what is contacted by default and what each setting adds. Unit tests show each request skipped while its setting is off and made while it is on; the Chrome suite watches every request from a fresh install, then turns each setting on. Model: opus-5-5
301 lines
12 KiB
JavaScript
301 lines
12 KiB
JavaScript
// 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[5]).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);
|
|
});
|
|
|
|
// 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);
|
|
});
|