Some checks failed
check / check (push) Has been cancelled
`make check` was green while the AddToken screen crashed on every open. `script/lint` is only `prettier --check`, so a used-but-not-imported identifier is invisible until a browser evaluates it. This adds a suite that runs the real popup in a real Chrome and treats any uncaught page error or console.error as a failure. - `script/test-e2e` (with `make test-e2e` as a thin shim) builds `dist/chrome/` and runs `tests/e2e/run.js` inside the Playwright image, pinned by digest. `playwright-core` is pinned to the matching 1.56.0 through `yarn.lock`; the two must be bumped together because the browsers ship inside the image. - Deliberately outside `script/test` and `script/check`: REPO_POLICIES caps `make test` at 20 seconds. Nothing under `tests/e2e/` is named `*.test.js`, so jest cannot pick it up either. - Launches with `channel: "chromium"`; the default headless shell silently refuses to load extensions with no error at all. The extension id is read from the service worker URL, never hardcoded. - All http(s) traffic is intercepted at the browser level and served from fixtures, so the run is deterministic and offline. Unrecognised outbound requests are reported as failures rather than allowed. - A missing build or an unavailable container fails loudly; a skip that looks like a pass is the failure mode this is meant to prevent. - One allowlisted page error, for the libsodium WASM CSP fallback tracked as #182, which is otherwise untouched here. The suite was demonstrated failing against the unfixed tree with `pageerror: showView is not defined` and `pageerror: addressDotHtml is not defined`, so it carries the two one-line import fixes it caught: closes #150 — `showView` restored to the destructure in `src/popup/views/addToken.js`, dropped bya22f33d, which made the AddToken screen unreachable and corrupted the navigation stack. closes #151 — `addressDotHtml` restored in `src/popup/views/transactionDetail.js`, dropped bydf031fd, which threw before `showView("transaction")` for every ERC-20 transfer. The shared `renderAddressHtml` helper is not used here on purpose: it hardcodes the `/address/` explorer URL, and this row needs the token-specific `/token/` link.
224 lines
7.4 KiB
JavaScript
224 lines
7.4 KiB
JavaScript
// Browser-level network interception for the end-to-end suite.
|
|
//
|
|
// Every http(s) request the extension makes is fulfilled from these
|
|
// fixtures, so the suite is deterministic and runs entirely offline. The
|
|
// probe that motivated this harness (see issue #181) observed live calls
|
|
// to Blockscout returning 401 inside the container, which would make any
|
|
// assertion about rendered transaction data worthless.
|
|
//
|
|
// Anything not explicitly stubbed here is aborted AND reported to the
|
|
// error collector, so a newly added outbound call shows up as a test
|
|
// failure rather than as intermittent flakiness.
|
|
|
|
"use strict";
|
|
|
|
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
|
// must not collide with any entry in src/shared/tokenList.js, or
|
|
// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a
|
|
// symbol-spoofing attempt; holders_count must be >= 1000 or the default
|
|
// hideLowHolderTokens filter drops it. Either would make the test pass
|
|
// vacuously by never rendering a row at all.
|
|
const STUB_TOKEN = {
|
|
address: "0xe2e0000000000000000000000000000000000e2e",
|
|
symbol: "E2E",
|
|
name: "End To End Test Token",
|
|
decimals: "6",
|
|
holders: "12345",
|
|
};
|
|
|
|
const STUB_COUNTERPARTY = "0xc0ffee0000000000000000000000000000c0ffee";
|
|
|
|
const STUB_TX_HASH =
|
|
"0xe2e0000000000000000000000000000000000000000000000000000000000e2e";
|
|
|
|
const STUB_BLOCK_NUMBER = 21000000;
|
|
|
|
// Fixed instant so timeAgo() output is stable across runs.
|
|
const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z";
|
|
|
|
// A 32-byte zero word. Returned for every eth_call, which is what makes
|
|
// ethers' ENS reverse lookup resolve to "no resolver set" and return null
|
|
// instead of throwing. A throw would be logged by src/shared/ens.js via
|
|
// log.errorf(), i.e. console.error, which fails the run on its own.
|
|
const ZERO_WORD = "0x" + "0".repeat(64);
|
|
|
|
const RPC_RESULTS = {
|
|
eth_chainId: "0x1",
|
|
net_version: "1",
|
|
eth_blockNumber: "0x1406f40",
|
|
eth_getBalance: "0x0",
|
|
eth_call: ZERO_WORD,
|
|
eth_gasPrice: "0x3b9aca00",
|
|
eth_estimateGas: "0x5208",
|
|
eth_getTransactionCount: "0x0",
|
|
eth_maxPriorityFeePerGas: "0x3b9aca00",
|
|
};
|
|
|
|
function tokenObject() {
|
|
return {
|
|
address_hash: STUB_TOKEN.address,
|
|
address: STUB_TOKEN.address,
|
|
symbol: STUB_TOKEN.symbol,
|
|
name: STUB_TOKEN.name,
|
|
decimals: STUB_TOKEN.decimals,
|
|
holders_count: STUB_TOKEN.holders,
|
|
type: "ERC-20",
|
|
};
|
|
}
|
|
|
|
// One received ERC-20 transfer of 1.5 E2E to the address under test.
|
|
function tokenTransferItems(address) {
|
|
return [
|
|
{
|
|
transaction_hash: STUB_TX_HASH,
|
|
block_number: STUB_BLOCK_NUMBER,
|
|
timestamp: STUB_TX_TIMESTAMP,
|
|
from: { hash: STUB_COUNTERPARTY },
|
|
to: { hash: address },
|
|
total: { decimals: STUB_TOKEN.decimals, value: "1500000" },
|
|
token: tokenObject(),
|
|
},
|
|
];
|
|
}
|
|
|
|
// Full details for STUB_TX_HASH. raw_input is "0x" so the calldata
|
|
// decoder short-circuits; the on-chain detail fields still populate.
|
|
function transactionDetails() {
|
|
return {
|
|
hash: STUB_TX_HASH,
|
|
block_number: STUB_BLOCK_NUMBER,
|
|
nonce: 7,
|
|
gas_used: "51000",
|
|
gas_price: "1000000000",
|
|
fee: { value: "51000000000000" },
|
|
raw_input: "0x",
|
|
status: "ok",
|
|
};
|
|
}
|
|
|
|
function jsonResponse(route, body) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
// Extract the address from a Blockscout /addresses/<addr>/... path.
|
|
function blockscoutAddress(pathname) {
|
|
const m = pathname.match(/\/addresses\/(0x[0-9a-fA-F]{40})\//);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
function handleRpc(route, postData, report) {
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(postData || "null");
|
|
} catch {
|
|
report("unstubbed RPC: unparseable body " + String(postData));
|
|
return route.abort();
|
|
}
|
|
// ethers batches by default, so the body may be an array.
|
|
const batch = Array.isArray(payload) ? payload : [payload];
|
|
const replies = batch.map((req) => {
|
|
const result = RPC_RESULTS[req.method];
|
|
if (result === undefined) {
|
|
report("unstubbed RPC method: " + req.method);
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id: req.id,
|
|
error: { code: -32601, message: "unstubbed in e2e harness" },
|
|
};
|
|
}
|
|
return { jsonrpc: "2.0", id: req.id, result };
|
|
});
|
|
return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]);
|
|
}
|
|
|
|
/**
|
|
* Route every http(s) request through local fixtures.
|
|
*
|
|
* @param {import("playwright-core").BrowserContext} ctx
|
|
* @param {object} opts
|
|
* @param {(text: string) => void} opts.report called for unstubbed traffic
|
|
* @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20
|
|
* transfer. Read at request time, so a test can flip it on the same
|
|
* options object without re-registering the route.
|
|
*/
|
|
async function installNetworkStubs(ctx, opts) {
|
|
const report = opts.report;
|
|
|
|
// Regex rather than a glob so chrome-extension:// resource loads are
|
|
// never touched — routing those would break the popup itself.
|
|
await ctx.route(/^https?:\/\//, async (route) => {
|
|
const req = route.request();
|
|
const url = new URL(req.url());
|
|
const p = url.pathname;
|
|
|
|
// JSON-RPC endpoint (any host): a POST with a JSON-RPC body.
|
|
if (req.method() === "POST") {
|
|
return handleRpc(route, req.postData(), report);
|
|
}
|
|
|
|
// Blockscout v2
|
|
if (p.includes("/api/v2/")) {
|
|
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
|
return jsonResponse(route, { items: [] });
|
|
}
|
|
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) {
|
|
const addr = blockscoutAddress(p);
|
|
return jsonResponse(route, {
|
|
items:
|
|
opts.seedTokenTransfer && addr
|
|
? tokenTransferItems(addr)
|
|
: [],
|
|
});
|
|
}
|
|
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) {
|
|
return jsonResponse(route, []);
|
|
}
|
|
if (p.endsWith("/transactions/" + STUB_TX_HASH)) {
|
|
return jsonResponse(route, transactionDetails());
|
|
}
|
|
}
|
|
|
|
// CoinDesk price tick
|
|
if (url.hostname.endsWith("coindesk.com")) {
|
|
return jsonResponse(route, { Data: {} });
|
|
}
|
|
|
|
// MetaMask phishing blocklist
|
|
if (
|
|
url.hostname === "raw.githubusercontent.com" ||
|
|
p.endsWith("/eth-phishing-detect/main/src/config.json")
|
|
) {
|
|
return jsonResponse(route, {
|
|
version: 2,
|
|
tolerance: 2,
|
|
fuzzylist: [],
|
|
whitelist: [],
|
|
blacklist: [],
|
|
});
|
|
}
|
|
|
|
// Best-effort Etherscan address labels: served as an empty page.
|
|
if (url.hostname.endsWith("etherscan.io")) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "text/html",
|
|
body: "<html><body></body></html>",
|
|
});
|
|
}
|
|
|
|
report("unstubbed request: " + req.method() + " " + req.url());
|
|
return route.abort();
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
installNetworkStubs,
|
|
STUB_TOKEN,
|
|
STUB_COUNTERPARTY,
|
|
STUB_TX_HASH,
|
|
};
|