1 Commits
Author SHA1 Message Date
sneak 15eea79b77 fix: Settings refuses an endpoint URL whose network changed before it was stored (closes #663)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The RPC and Blockscout "Save" buttons in Settings take the network
Settings was last drawn for. A follow of a switch saved elsewhere sets
`state` first. Settings is drawn again only once the follow's write
returns.

The URL is set in `state` inside the save, holding the lock. The save is
refused if the network Settings showed is no longer active, on the page
or in the stored record. The flash message then says that the network
changed, and "Saved." is shown only for a stored URL.

Before, a popup that followed a switch saved elsewhere could replace the
URL before the write and still show "Saved.". It could also store a URL
for a network Settings did not show.

Model: opus-5-5
2026-10-27 04:34:04 +00:00
2 changed files with 11 additions and 126 deletions
+11 -11
View File
@@ -44,10 +44,10 @@ let ctx;
let versionClickCount = 0; let versionClickCount = 0;
let versionClickTimer = null; let versionClickTimer = null;
// The network that Settings was last drawn for. A follow of a switch saved // The network Settings was last drawn for. A follow of a switch saved elsewhere
// elsewhere sets `state` to the new network first. Settings is drawn again only // sets `state` to the new network first. Settings is drawn again only once the
// once the follow's write returns. So both "Save" buttons take this network, // follow's write returns. So both "Save" buttons take this network, not the one
// not the one in `state`. // in `state`.
let shownNetwork = null; let shownNetwork = null;
// One row per site origin, however many addresses it appears under, each with // One row per site origin, however many addresses it appears under, each with
@@ -328,12 +328,12 @@ function showNewSettings() {
} }
// Saves `url` as `field` ("rpcUrl" or "blockscoutUrl") of the network // Saves `url` as `field` ("rpcUrl" or "blockscoutUrl") of the network
// `networkId`, the one that Settings showed when the user tapped "Save". The // `networkId`, the one Settings showed when the user tapped "Save". The URL is
// URL is set in `state` inside the save, holding the lock. So the page cannot // set in `state` inside the save, holding the lock. So the page cannot follow a
// follow a switch saved elsewhere (followStoredSettings() in // switch saved elsewhere (followStoredSettings() in src/popup/index.js) between
// src/popup/index.js) between setting the URL and writing it. If the active // setting the URL and writing it. If the active network is no longer
// network is no longer `networkId`, on this page or in the stored record, // `networkId`, on this page or in the stored record, nothing is saved. The
// nothing is saved. The flash message then says that the network changed. // flash message then says that the network changed.
async function saveEndpoint(field, url, networkId) { async function saveEndpoint(field, url, networkId) {
let refused = false; let refused = false;
await saveState((stored) => { await saveState((stored) => {
@@ -360,7 +360,7 @@ function init(_ctx) {
showFlash("Please enter an RPC URL."); showFlash("Please enter an RPC URL.");
return; return;
} }
// The URL is for the network that Settings shows when "Save" is tapped. // The URL is for the network Settings shows when "Save" is tapped.
const net = shownNetwork; const net = shownNetwork;
showFlash("Testing endpoint..."); showFlash("Testing endpoint...");
try { try {
-115
View File
@@ -403,121 +403,6 @@ test("the RPC check compares the answer with the network at the tap", async () =
); );
}); });
// 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 // 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 // or writes the stored record for it: the popup's next save finds it, as
// before. // before.