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
394 lines
14 KiB
JavaScript
394 lines
14 KiB
JavaScript
// 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 }),
|
|
};
|
|
|
|
// 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.");
|
|
},
|
|
);
|