1 Commits
Author SHA1 Message Date
sneak 3add1a158b 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 that
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 that 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 that Settings did not show.

Model: opus-5-5
2026-10-27 05:36:11 +00:00
2 changed files with 126 additions and 11 deletions

No files matched your search

+11 -11
View File
@@ -44,10 +44,10 @@ let ctx;
let versionClickCount = 0; let versionClickCount = 0;
let versionClickTimer = null; let versionClickTimer = null;
// The network Settings was last drawn for. A follow of a switch saved elsewhere // The network that Settings was last drawn for. A follow of a switch saved
// sets `state` to the new network first. Settings is drawn again only once the // elsewhere sets `state` to the new network first. Settings is drawn again only
// follow's write returns. So both "Save" buttons take this network, not the one // once the follow's write returns. So both "Save" buttons take this network,
// in `state`. // not the one 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 Settings showed when the user tapped "Save". The URL is // `networkId`, the one that Settings showed when the user tapped "Save". The
// set in `state` inside the save, holding the lock. So the page cannot follow a // URL is set in `state` inside the save, holding the lock. So the page cannot
// switch saved elsewhere (followStoredSettings() in src/popup/index.js) between // follow a switch saved elsewhere (followStoredSettings() in
// setting the URL and writing it. If the active network is no longer // src/popup/index.js) between setting the URL and writing it. If the active
// `networkId`, on this page or in the stored record, nothing is saved. The // network is no longer `networkId`, on this page or in the stored record,
// flash message then says that the network changed. // nothing is saved. The 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 Settings shows when "Save" is tapped. // The URL is for the network that Settings shows when "Save" is tapped.
const net = shownNetwork; const net = shownNetwork;
showFlash("Testing endpoint..."); showFlash("Testing endpoint...");
try { try {
+115
View File
@@ -403,6 +403,121 @@ 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.