fix: Settings ignores an endpoint check once the same "Save" is tapped again (closes #698)
Each RPC and Blockscout "Save" in Settings now counts its taps. A check that answers after a newer tap of the same "Save" stores nothing and leaves the flash message alone. Before this change, the older check's error or refusal replaced the newer tap's "Saved.". The save also checks for a newer tap at two points. A tap made while the save waits for the lock means nothing is stored. A tap made during the write means no flash message is shown. Model: opus-5-5
This commit is contained in:
@@ -167,3 +167,227 @@ test.each([
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||
const PASSING_ANSWER = {
|
||||
ok: true,
|
||||
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||
};
|
||||
|
||||
// Both endpoint checks read this answer as an error. The RPC check reads the
|
||||
// answer's `error`. The Blockscout check reads the answer's `ok`.
|
||||
const ERROR_ANSWER = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: { code: -32603, message: "Internal error" } }),
|
||||
};
|
||||
|
||||
// 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 ends in one of three ways:
|
||||
//
|
||||
// - It passes.
|
||||
// - It fails, because fetch cannot reach the endpoint.
|
||||
// - It gets an answer that holds an error.
|
||||
//
|
||||
// None of the three changes the stored URL or the flash message
|
||||
// (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-rpc",
|
||||
input: "settings-rpc",
|
||||
field: "rpcUrl",
|
||||
first: "answers with an error",
|
||||
},
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
first: "passes",
|
||||
},
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
first: "fails",
|
||||
},
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
first: "answers with an error",
|
||||
},
|
||||
])(
|
||||
"$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 if (first === "answers with an error") {
|
||||
checks[0].resolve(ERROR_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.");
|
||||
},
|
||||
);
|
||||
|
||||
// The user taps "Save", and the check passes. The save then begins its storage
|
||||
// write, and this test keeps the write waiting. The user taps "Save" again, and
|
||||
// the second check cannot reach the endpoint. When the write ends, the flash
|
||||
// message still reads "Could not reach endpoint.". The write stores the URL
|
||||
// anyway, because the write began before the second tap.
|
||||
test.each([
|
||||
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||
{
|
||||
button: "btn-save-blockscout",
|
||||
input: "settings-blockscout",
|
||||
field: "blockscoutUrl",
|
||||
},
|
||||
])(
|
||||
"$button keeps the second tap's message when tapped again during the write",
|
||||
async ({ button, input, field }) => {
|
||||
const page = await bootPopup(unversionedValidProfile());
|
||||
await page.click("btn-settings");
|
||||
const checks = [];
|
||||
globalThis.fetch.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
checks.push({ resolve, reject });
|
||||
}),
|
||||
);
|
||||
const typedUrl = "https://typed.example.invalid";
|
||||
|
||||
page.node(input).value = typedUrl;
|
||||
const firstTap = page.node(button).listeners.click.map((fn) => fn());
|
||||
await page.settle();
|
||||
const { set } = page.storage.local;
|
||||
const write = set.getMockImplementation();
|
||||
let writeBegan = false;
|
||||
let releaseWrite;
|
||||
const writeHeld = new Promise((resolve) => {
|
||||
releaseWrite = resolve;
|
||||
});
|
||||
set.mockImplementationOnce(async (items) => {
|
||||
writeBegan = true;
|
||||
await writeHeld;
|
||||
await write(items);
|
||||
});
|
||||
checks[0].resolve(PASSING_ANSWER);
|
||||
await page.settle();
|
||||
expect(writeBegan).toBe(true);
|
||||
const secondTap = page.node(button).listeners.click.map((fn) => fn());
|
||||
await page.settle();
|
||||
// fetch rejects when it cannot reach the endpoint.
|
||||
checks[1].reject(new TypeError("Failed to fetch"));
|
||||
await Promise.all(secondTap);
|
||||
await page.settle();
|
||||
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
|
||||
releaseWrite();
|
||||
await Promise.all(firstTap);
|
||||
await page.settle();
|
||||
|
||||
expect(checks).toHaveLength(2);
|
||||
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
|
||||
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user