Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
666c893a08 | ||
|
|
cd5951a4f8 | ||
|
|
bcdffa56a4 |
@@ -2279,6 +2279,11 @@ view would leave a wallet one click from deletion.
|
||||
no user data and is made whether or not "Use Blockscout" is ticked.
|
||||
- If the network changes before either "Save" stores its URL, the URL is not
|
||||
saved and a flash message says so
|
||||
- Either "Save" stores its URL only if the input still shows that URL when
|
||||
the endpoint answers. Otherwise a flash message says that the URL was not
|
||||
saved
|
||||
- If the user taps the same "Save" again before the endpoint answers, the
|
||||
earlier tap stores nothing and shows no flash message
|
||||
- Etherscan:
|
||||
- "Check recipients on Etherscan" checkbox, unticked until the user
|
||||
ticks it
|
||||
|
||||
@@ -50,6 +50,11 @@ let versionClickTimer = null;
|
||||
// not the one in `state`.
|
||||
let shownNetwork = null;
|
||||
|
||||
// Each endpoint "Save" counts its taps here, under the field that it stores. A
|
||||
// tap stores its URL and shows a flash message only while it is the latest tap
|
||||
// of its "Save".
|
||||
const saveTaps = { rpcUrl: 0, blockscoutUrl: 0 };
|
||||
|
||||
// One row per site origin, however many addresses it appears under, each with
|
||||
// an [x] that hands it to onRemove.
|
||||
function renderSiteList(containerId, origins, onRemove) {
|
||||
@@ -334,16 +339,43 @@ function showNewSettings() {
|
||||
// src/popup/index.js) between setting the URL and writing it. If the active
|
||||
// network is no longer `networkId`, on this page or in the stored record,
|
||||
// nothing is saved. The flash message then says that the network changed.
|
||||
async function saveEndpoint(field, url, networkId) {
|
||||
//
|
||||
// The save writes only the fields that differ from what this page last saved.
|
||||
// Another page may have stored a different URL since then. So `url` is set in
|
||||
// the stored record too. "Saved." then means that the stored record holds
|
||||
// `url`.
|
||||
//
|
||||
// `input` is the id of the input that the user typed `url` in. When another
|
||||
// page changes the network, "Use Blockscout" or "Check recipients on
|
||||
// Etherscan", this page draws Settings again. A redraw during the endpoint
|
||||
// check puts the URL from `state` back in the input. So if the input no longer
|
||||
// shows `url`, nothing is saved. The flash message then says that the URL was
|
||||
// changed during the check. The save compares the input and sets `url` in
|
||||
// `state` in one step. So a redraw after the comparison shows `url`.
|
||||
//
|
||||
// `tap` is the number of the "Save" tap that checked `url`. If the user taps
|
||||
// the same "Save" again before this save holds the lock, nothing is stored. If
|
||||
// the user taps the same "Save" again before the write ends, no flash message
|
||||
// is shown.
|
||||
async function saveEndpoint(field, input, url, networkId, tap) {
|
||||
let refused = false;
|
||||
let inputChanged = false;
|
||||
await saveState((stored) => {
|
||||
if (saveTaps[field] !== tap) return false;
|
||||
refused =
|
||||
state.networkId !== networkId || stored.networkId !== networkId;
|
||||
if (refused) return false;
|
||||
inputChanged = $(input).value.trim() !== url;
|
||||
if (refused || inputChanged) return false;
|
||||
state[field] = url;
|
||||
stored[field] = url;
|
||||
});
|
||||
if (saveTaps[field] !== tap) return;
|
||||
if (refused) {
|
||||
showFlash("The URL was not saved because the network changed.");
|
||||
} else if (inputChanged) {
|
||||
showFlash(
|
||||
"The URL was not saved because it was changed during the check.",
|
||||
);
|
||||
} else {
|
||||
showFlash("Saved.");
|
||||
}
|
||||
@@ -355,6 +387,8 @@ function init(_ctx) {
|
||||
showPhrase.init();
|
||||
|
||||
$("btn-save-rpc").addEventListener("click", async () => {
|
||||
saveTaps.rpcUrl++;
|
||||
const tap = saveTaps.rpcUrl;
|
||||
const url = $("settings-rpc").value.trim();
|
||||
if (!url) {
|
||||
showFlash("Please enter an RPC URL.");
|
||||
@@ -375,6 +409,8 @@ function init(_ctx) {
|
||||
}),
|
||||
});
|
||||
const json = await resp.json();
|
||||
// The user has tapped this "Save" again since the check began.
|
||||
if (tap !== saveTaps.rpcUrl) return;
|
||||
if (json.error) {
|
||||
log.errorf("RPC validation error:", json.error);
|
||||
showFlash("Endpoint returned an error.");
|
||||
@@ -388,6 +424,7 @@ function init(_ctx) {
|
||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||
// src/popup/index.js.
|
||||
if (ctx.pageClosed.aborted) return;
|
||||
if (tap !== saveTaps.rpcUrl) return;
|
||||
// Not the error's message: fetch puts the whole URL, password and
|
||||
// key included, in the message of the error it throws for a URL
|
||||
// with a user name and password or one it cannot parse.
|
||||
@@ -395,10 +432,12 @@ function init(_ctx) {
|
||||
showFlash("Could not reach endpoint.");
|
||||
return;
|
||||
}
|
||||
await saveEndpoint("rpcUrl", url, net.id);
|
||||
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id, tap);
|
||||
});
|
||||
|
||||
$("btn-save-blockscout").addEventListener("click", async () => {
|
||||
saveTaps.blockscoutUrl++;
|
||||
const tap = saveTaps.blockscoutUrl;
|
||||
const url = $("settings-blockscout").value.trim();
|
||||
if (!url) {
|
||||
showFlash("Please enter a Blockscout API URL.");
|
||||
@@ -408,18 +447,26 @@ function init(_ctx) {
|
||||
showFlash("Testing endpoint...");
|
||||
try {
|
||||
const resp = await debugFetch(url + "/stats");
|
||||
if (tap !== saveTaps.blockscoutUrl) return;
|
||||
if (!resp.ok) {
|
||||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (ctx.pageClosed.aborted) return;
|
||||
if (tap !== saveTaps.blockscoutUrl) return;
|
||||
// Not the error's message, as for the RPC check above.
|
||||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
||||
showFlash("Could not reach endpoint.");
|
||||
return;
|
||||
}
|
||||
await saveEndpoint("blockscoutUrl", url, net.id);
|
||||
await saveEndpoint(
|
||||
"blockscoutUrl",
|
||||
"settings-blockscout",
|
||||
url,
|
||||
net.id,
|
||||
tap,
|
||||
);
|
||||
});
|
||||
|
||||
// Each change is made inside the save, holding the lock. So
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
// Another page stores a different RPC or Blockscout URL for the same network.
|
||||
// Then the user taps "Save" in Settings on this page, on the URL that this page
|
||||
// loaded. The URL on screen is stored, and the flash message reads "Saved."
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/688). Driven through the real
|
||||
// popup over a stored profile on Ethereum Mainnet.
|
||||
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
unversionedValidProfile,
|
||||
} = require("./support/popupBoot");
|
||||
const { NETWORKS } = require("../src/shared/networks");
|
||||
|
||||
afterEach(() => {
|
||||
cleanupPopup();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
},
|
||||
])(
|
||||
"$button stores the URL on screen after another page stored a different one",
|
||||
async ({ button, input, field }) => {
|
||||
const page = await bootPopup(unversionedValidProfile());
|
||||
await page.click("btn-settings");
|
||||
const url = page.value(input);
|
||||
// This write sends this page no storage change event.
|
||||
page.storage.write("autistmask", {
|
||||
...page.storage.read("autistmask"),
|
||||
[field]: "https://other-page.example.invalid",
|
||||
});
|
||||
// This answer passes both endpoint checks on Ethereum Mainnet. The RPC
|
||||
// check reads Ethereum Mainnet's chain id from it. The Blockscout check
|
||||
// reads `ok`.
|
||||
globalThis.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||
});
|
||||
|
||||
await page.click(button);
|
||||
|
||||
expect(page.storage.read("autistmask")[field]).toBe(url);
|
||||
expect(page.text("flash-msg")).toBe("Saved.");
|
||||
},
|
||||
);
|
||||
|
||||
// The user types a URL in Settings and taps "Save". While the check waits for
|
||||
// the endpoint's answer, another page turns "Use Blockscout" on. This page
|
||||
// follows that change and draws Settings again, with the stored URL in the
|
||||
// input. The check then passes. The typed URL is not stored. The flash message
|
||||
// says that the URL was changed during the check
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/695).
|
||||
test.each([
|
||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
},
|
||||
])(
|
||||
"$button stores nothing when Settings is drawn again during the check",
|
||||
async ({ button, input, field }) => {
|
||||
const page = await bootPopup(unversionedValidProfile());
|
||||
await page.click("btn-settings");
|
||||
const storedUrl = page.value(input);
|
||||
let answer;
|
||||
globalThis.fetch.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
page.node(input).value = "https://typed.example.invalid";
|
||||
const saving = page.node(button).listeners.click.map((fn) => fn());
|
||||
await page.settle();
|
||||
// The browser announces this write to this page.
|
||||
await page.storage.local.set({
|
||||
autistmask: {
|
||||
...page.storage.read("autistmask"),
|
||||
useBlockscout: true,
|
||||
},
|
||||
});
|
||||
await page.settle();
|
||||
expect(page.node("settings-use-blockscout").checked).toBe(true);
|
||||
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||
answer({
|
||||
ok: true,
|
||||
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||
});
|
||||
await Promise.all(saving);
|
||||
await page.settle();
|
||||
|
||||
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
|
||||
expect(page.value(input)).toBe(storedUrl);
|
||||
expect(page.text("flash-msg")).toBe(
|
||||
"The URL was not saved because it was changed during the check.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// As above, but another write holds the lock. So this page's follow of the
|
||||
// change to "Use Blockscout" waits for the lock. The endpoint answers while the
|
||||
// input still shows the typed URL. The save then asks for the lock, after the
|
||||
// follow did. So the follow draws Settings again before the save compares the
|
||||
// input.
|
||||
test.each([
|
||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
},
|
||||
])(
|
||||
"$button stores nothing when Settings is drawn again while the save waits for the lock",
|
||||
async ({ button, input, field }) => {
|
||||
const page = await bootPopup(unversionedValidProfile());
|
||||
await page.click("btn-settings");
|
||||
const storedUrl = page.value(input);
|
||||
let answer;
|
||||
globalThis.fetch.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
);
|
||||
const typedUrl = "https://typed.example.invalid";
|
||||
|
||||
page.node(input).value = typedUrl;
|
||||
const saving = page.node(button).listeners.click.map((fn) => fn());
|
||||
await page.settle();
|
||||
let releaseLock;
|
||||
const lockHeld = navigator.locks.request(
|
||||
"autistmask",
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseLock = resolve;
|
||||
}),
|
||||
);
|
||||
// The browser announces this write to this page.
|
||||
await page.storage.local.set({
|
||||
autistmask: {
|
||||
...page.storage.read("autistmask"),
|
||||
useBlockscout: true,
|
||||
},
|
||||
});
|
||||
await page.settle();
|
||||
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||
answer({
|
||||
ok: true,
|
||||
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||
});
|
||||
await page.settle();
|
||||
expect(page.value(input)).toBe(typedUrl);
|
||||
releaseLock();
|
||||
await lockHeld;
|
||||
await Promise.all(saving);
|
||||
await page.settle();
|
||||
|
||||
expect(page.node("settings-use-blockscout").checked).toBe(true);
|
||||
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
|
||||
expect(page.value(input)).toBe(storedUrl);
|
||||
expect(page.text("flash-msg")).toBe(
|
||||
"The URL was not saved because it was changed during the check.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||
const PASSING_ANSWER = {
|
||||
ok: true,
|
||||
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||
};
|
||||
|
||||
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
|
||||
// the user types a second URL and taps "Save" again. The second check passes
|
||||
// first. The second URL is stored, and the flash message reads "Saved.". The
|
||||
// first check then passes or fails. That answer changes nothing
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/698).
|
||||
test.each([
|
||||
{
|
||||
button: "btn-save-rpc",
|
||||
input: "settings-rpc",
|
||||
field: "rpcUrl",
|
||||
first: "passes",
|
||||
},
|
||||
{
|
||||
button: "btn-save-rpc",
|
||||
input: "settings-rpc",
|
||||
field: "rpcUrl",
|
||||
first: "fails",
|
||||
},
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
first: "passes",
|
||||
},
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
first: "fails",
|
||||
},
|
||||
])(
|
||||
"$button keeps the second tap's result when the first check $first last",
|
||||
async ({ button, input, field, first }) => {
|
||||
const page = await bootPopup(unversionedValidProfile());
|
||||
await page.click("btn-settings");
|
||||
const checks = [];
|
||||
globalThis.fetch.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
checks.push({ resolve, reject });
|
||||
}),
|
||||
);
|
||||
const secondUrl = "https://second.example.invalid";
|
||||
|
||||
page.node(input).value = "https://first.example.invalid";
|
||||
const firstTap = page.node(button).listeners.click.map((fn) => fn());
|
||||
await page.settle();
|
||||
page.node(input).value = secondUrl;
|
||||
const secondTap = page.node(button).listeners.click.map((fn) => fn());
|
||||
await page.settle();
|
||||
checks[1].resolve(PASSING_ANSWER);
|
||||
await Promise.all(secondTap);
|
||||
await page.settle();
|
||||
expect(page.text("flash-msg")).toBe("Saved.");
|
||||
if (first === "passes") {
|
||||
checks[0].resolve(PASSING_ANSWER);
|
||||
} else {
|
||||
// fetch rejects when it cannot reach the endpoint.
|
||||
checks[0].reject(new TypeError("Failed to fetch"));
|
||||
}
|
||||
await Promise.all(firstTap);
|
||||
await page.settle();
|
||||
|
||||
expect(checks).toHaveLength(2);
|
||||
expect(page.storage.read("autistmask")[field]).toBe(secondUrl);
|
||||
expect(page.text("flash-msg")).toBe("Saved.");
|
||||
},
|
||||
);
|
||||
|
||||
// The user types a URL in Settings and taps "Save". Another write holds the
|
||||
// lock. So the save waits for the lock once the check passes. The user then
|
||||
// taps "Save" again on the same URL. When the lock is free, the first save
|
||||
// stores nothing and shows no flash message. The second check then passes, and
|
||||
// the second save stores the URL.
|
||||
test.each([
|
||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
},
|
||||
])(
|
||||
"$button stores nothing for an earlier tap still waiting for the lock",
|
||||
async ({ button, input, field }) => {
|
||||
const page = await bootPopup(unversionedValidProfile());
|
||||
await page.click("btn-settings");
|
||||
const storedUrl = page.value(input);
|
||||
const checks = [];
|
||||
globalThis.fetch.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
checks.push(resolve);
|
||||
}),
|
||||
);
|
||||
const typedUrl = "https://typed.example.invalid";
|
||||
|
||||
page.node(input).value = typedUrl;
|
||||
const firstTap = page.node(button).listeners.click.map((fn) => fn());
|
||||
await page.settle();
|
||||
let releaseLock;
|
||||
const lockHeld = navigator.locks.request(
|
||||
"autistmask",
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseLock = resolve;
|
||||
}),
|
||||
);
|
||||
checks[0](PASSING_ANSWER);
|
||||
await page.settle();
|
||||
const secondTap = page.node(button).listeners.click.map((fn) => fn());
|
||||
await page.settle();
|
||||
releaseLock();
|
||||
await lockHeld;
|
||||
await Promise.all(firstTap);
|
||||
await page.settle();
|
||||
|
||||
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
|
||||
expect(page.text("flash-msg")).toBe("Testing endpoint...");
|
||||
|
||||
checks[1](PASSING_ANSWER);
|
||||
await Promise.all(secondTap);
|
||||
await page.settle();
|
||||
|
||||
expect(checks).toHaveLength(2);
|
||||
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
|
||||
expect(page.text("flash-msg")).toBe("Saved.");
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user