Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a54101d6c | ||
|
|
7ba0a5ff75 |
No files matched your search
@@ -311,10 +311,21 @@ function populateOnChainDetails(txData, chainId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether TransactionDetail still holds the transaction with `txHash` on
|
||||||
|
// `chainId`. Opening another transaction replaces the one TransactionDetail
|
||||||
|
// holds. A visit to Settings does not.
|
||||||
|
function holdsTransaction(txHash, chainId) {
|
||||||
|
const tx = state.viewData.tx;
|
||||||
|
return tx?.hash === txHash && tx?.chainId === chainId;
|
||||||
|
}
|
||||||
|
|
||||||
// Reads the block, network details and calldata from Blockscout, and decodes
|
// Reads the block, network details and calldata from Blockscout, and decodes
|
||||||
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
||||||
// asked. An answer that lands after the setting went off is dropped
|
// asked. An answer that lands after the setting went off is dropped
|
||||||
// (src/shared/storedSettings.js). Either way none of them is shown.
|
// (src/shared/storedSettings.js). Either way none of them is shown.
|
||||||
|
//
|
||||||
|
// Blockscout can answer after the user has opened another transaction. That
|
||||||
|
// answer is dropped.
|
||||||
async function loadFullTxDetails(txHash, chainId) {
|
async function loadFullTxDetails(txHash, chainId) {
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||||
const section = $("tx-detail-calldata-section");
|
const section = $("tx-detail-calldata-section");
|
||||||
@@ -333,6 +344,7 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const txData = await resp.json();
|
const txData = await resp.json();
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||||
|
if (!holdsTransaction(txHash, chainId)) return;
|
||||||
|
|
||||||
// Populate on-chain detail fields (block, nonce, gas, fee)
|
// Populate on-chain detail fields (block, nonce, gas, fee)
|
||||||
populateOnChainDetails(txData, chainId);
|
populateOnChainDetails(txData, chainId);
|
||||||
|
|||||||
@@ -174,10 +174,24 @@ const PASSING_ANSWER = {
|
|||||||
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
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 URL in Settings and taps "Save". Before the endpoint answers,
|
||||||
// the user types a second URL and taps "Save" again. The second check passes
|
// 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. The second URL is stored, and the flash message reads "Saved.". The
|
||||||
// first check then passes or fails. That answer changes nothing
|
// 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).
|
// (https://git.eeqj.de/sneak/AutistMask/issues/698).
|
||||||
test.each([
|
test.each([
|
||||||
{
|
{
|
||||||
@@ -192,6 +206,12 @@ test.each([
|
|||||||
field: "rpcUrl",
|
field: "rpcUrl",
|
||||||
first: "fails",
|
first: "fails",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
button: "btn-save-rpc",
|
||||||
|
input: "settings-rpc",
|
||||||
|
field: "rpcUrl",
|
||||||
|
first: "answers with an error",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
button: "btn-save-blockscout",
|
button: "btn-save-blockscout",
|
||||||
input: "settings-blockscout",
|
input: "settings-blockscout",
|
||||||
@@ -204,6 +224,12 @@ test.each([
|
|||||||
field: "blockscoutUrl",
|
field: "blockscoutUrl",
|
||||||
first: "fails",
|
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",
|
"$button keeps the second tap's result when the first check $first last",
|
||||||
async ({ button, input, field, first }) => {
|
async ({ button, input, field, first }) => {
|
||||||
@@ -230,6 +256,8 @@ test.each([
|
|||||||
expect(page.text("flash-msg")).toBe("Saved.");
|
expect(page.text("flash-msg")).toBe("Saved.");
|
||||||
if (first === "passes") {
|
if (first === "passes") {
|
||||||
checks[0].resolve(PASSING_ANSWER);
|
checks[0].resolve(PASSING_ANSWER);
|
||||||
|
} else if (first === "answers with an error") {
|
||||||
|
checks[0].resolve(ERROR_ANSWER);
|
||||||
} else {
|
} else {
|
||||||
// fetch rejects when it cannot reach the endpoint.
|
// fetch rejects when it cannot reach the endpoint.
|
||||||
checks[0].reject(new TypeError("Failed to fetch"));
|
checks[0].reject(new TypeError("Failed to fetch"));
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// TransactionDetail shows only details that Blockscout gave for the
|
||||||
|
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
|
||||||
|
//
|
||||||
|
// The screen asks Blockscout for its transaction and does not wait for the
|
||||||
|
// answer. So the user can press Back and open another transaction before
|
||||||
|
// Blockscout answers for the first. The first answer must then fill nothing on
|
||||||
|
// the second transaction's screen. An answer that lands while Settings is up
|
||||||
|
// must still fill TransactionDetail.
|
||||||
|
//
|
||||||
|
// Driven through the real popup over a stored profile
|
||||||
|
// (tests/support/popupBoot.js). Each transaction is opened as a history screen
|
||||||
|
// opens it: the current screen is pushed, then transactionDetail.show() draws
|
||||||
|
// the transaction.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
|
||||||
|
const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
||||||
|
const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2";
|
||||||
|
const RECIPIENT = "0x2222222222222222222222222222222222222222";
|
||||||
|
|
||||||
|
// A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the
|
||||||
|
// network with `chainId`.
|
||||||
|
function historyEntry(hash, chainId, symbol) {
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
from: ADDRESS,
|
||||||
|
to: RECIPIENT,
|
||||||
|
value: "1.0000",
|
||||||
|
exactValue: "1.0",
|
||||||
|
symbol,
|
||||||
|
timestamp: 1790000000,
|
||||||
|
isError: false,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
contractAddress: null,
|
||||||
|
chainId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH");
|
||||||
|
const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH");
|
||||||
|
// A transaction on Sepolia with the same hash as FIRST.
|
||||||
|
const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH");
|
||||||
|
|
||||||
|
// The URL that asks Blockscout for `entry`, on the network it was sent on.
|
||||||
|
function requestUrl(entry) {
|
||||||
|
const blockscout =
|
||||||
|
entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT;
|
||||||
|
return blockscout + "/transactions/" + entry.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What Blockscout reports for each transaction, by the URL that asks for it.
|
||||||
|
const ANSWERS = {
|
||||||
|
[requestUrl(FIRST)]: {
|
||||||
|
block_number: 21000001,
|
||||||
|
nonce: 4441,
|
||||||
|
fee: { value: "21000000000000" },
|
||||||
|
raw_input: "0x11111111",
|
||||||
|
method: "firstMethod",
|
||||||
|
},
|
||||||
|
[requestUrl(SECOND)]: {
|
||||||
|
block_number: 21000002,
|
||||||
|
nonce: 4442,
|
||||||
|
fee: { value: "42000000000000" },
|
||||||
|
raw_input: "0x22222222",
|
||||||
|
method: "secondMethod",
|
||||||
|
},
|
||||||
|
[requestUrl(FIRST_ON_SEPOLIA)]: {
|
||||||
|
block_number: 21000003,
|
||||||
|
nonce: 4443,
|
||||||
|
fee: { value: "63000000000000" },
|
||||||
|
raw_input: "0x33333333",
|
||||||
|
method: "sepoliaMethod",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The text each answer puts on screen: its block, nonce, fee, method name and
|
||||||
|
// raw data. No text from one answer appears in another.
|
||||||
|
const FIRST_SHOWN = [
|
||||||
|
"21000001",
|
||||||
|
"4441",
|
||||||
|
"0.000021 ETH",
|
||||||
|
"firstMethod",
|
||||||
|
"0x11111111",
|
||||||
|
];
|
||||||
|
const SECOND_SHOWN = [
|
||||||
|
"21000002",
|
||||||
|
"4442",
|
||||||
|
"0.000042 ETH",
|
||||||
|
"secondMethod",
|
||||||
|
"0x22222222",
|
||||||
|
];
|
||||||
|
const FIRST_ON_SEPOLIA_SHOWN = [
|
||||||
|
"21000003",
|
||||||
|
"4443",
|
||||||
|
"0.000063 SepoliaETH",
|
||||||
|
"sepoliaMethod",
|
||||||
|
"0x33333333",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Everything drawn in the fields that Blockscout's answer fills.
|
||||||
|
function drawnDetails(page) {
|
||||||
|
return [
|
||||||
|
"tx-detail-block",
|
||||||
|
"tx-detail-nonce",
|
||||||
|
"tx-detail-fee",
|
||||||
|
"tx-detail-calldata-action",
|
||||||
|
"tx-detail-rawdata",
|
||||||
|
]
|
||||||
|
.map((id) => page.node(id).innerHTML + page.node(id).textContent)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens `entry` on TransactionDetail, as a history screen does.
|
||||||
|
function open(entry) {
|
||||||
|
require("../src/popup/views/helpers").pushCurrentView();
|
||||||
|
require("../src/popup/views/transactionDetail").show(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The URLs of every request to Blockscout so far, in order.
|
||||||
|
function requestedUrls() {
|
||||||
|
return globalThis.fetch.mock.calls.map(([url]) => url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boots the popup on Home with "Use Blockscout" on. Blockscout answers every
|
||||||
|
// request at once from ANSWERS, except the request for `held`. Returns the
|
||||||
|
// page and answer(), which lets the answer for `held` land.
|
||||||
|
async function bootWithHeldRequest(held) {
|
||||||
|
const page = await bootPopup(
|
||||||
|
unversionedValidProfile({ useBlockscout: true }),
|
||||||
|
);
|
||||||
|
let answer;
|
||||||
|
const heldAnswer = new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
});
|
||||||
|
globalThis.fetch = jest.fn(async (url) => {
|
||||||
|
if (url === requestUrl(held)) await heldAnswer;
|
||||||
|
return { ok: true, status: 200, json: async () => ANSWERS[url] };
|
||||||
|
});
|
||||||
|
return { page, answer: () => answer() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user opens `first`, whose request to Blockscout is held. Then the user
|
||||||
|
// presses Back and opens `second`. Then the answer for `first` lands. Returns
|
||||||
|
// the page.
|
||||||
|
async function firstAnswerLandsOnSecond(first, second) {
|
||||||
|
const { page, answer } = await bootWithHeldRequest(first);
|
||||||
|
open(first);
|
||||||
|
await page.settle();
|
||||||
|
await page.click("btn-tx-back");
|
||||||
|
expect(page.visibleViews()).toEqual(["main"]);
|
||||||
|
open(second);
|
||||||
|
await page.settle();
|
||||||
|
expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]);
|
||||||
|
answer();
|
||||||
|
await page.settle();
|
||||||
|
expect(page.visibleViews()).toEqual(["transaction"]);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
|
||||||
|
const page = await firstAnswerLandsOnSecond(FIRST, SECOND);
|
||||||
|
|
||||||
|
const drawn = drawnDetails(page);
|
||||||
|
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
|
||||||
|
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => {
|
||||||
|
const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA);
|
||||||
|
|
||||||
|
const drawn = drawnDetails(page);
|
||||||
|
for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text);
|
||||||
|
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer that lands while Settings is up still fills TransactionDetail", async () => {
|
||||||
|
const { page, answer } = await bootWithHeldRequest(FIRST);
|
||||||
|
open(FIRST);
|
||||||
|
await page.settle();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
expect(page.visibleViews()).toEqual(["settings"]);
|
||||||
|
answer();
|
||||||
|
await page.settle();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
|
||||||
|
expect(page.visibleViews()).toEqual(["transaction"]);
|
||||||
|
expect(requestedUrls()).toEqual([requestUrl(FIRST)]);
|
||||||
|
const drawn = drawnDetails(page);
|
||||||
|
for (const text of FIRST_SHOWN) expect(drawn).toContain(text);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user