// 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."); }, );