// Browser-level network interception for the end-to-end suite. // // Every http(s) request the extension makes — from the popup page AND // from the MV3 background service worker — 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. // // Service-worker coverage is not free: ctx.route() only sees worker // traffic when PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 is set in // the environment, which script/test-e2e does. Without it the phishing // blocklist fetch that src/background/index.js issues at worker startup // silently reaches raw.githubusercontent.com on the open internet, and // src/shared/phishingDomains.js swallows the failure so nothing surfaces // it. That is not left to trust: waitForServiceWorkerTraffic() below // backs the launch-time canary in harness.js, which fails the entire // suite if worker requests stop being visible here. // // 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"; const { Transaction } = require("ethers"); // 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; // The native ETH transfer, seeded by opts.seedNativeTransfer. Its own hash // and an older block, so it is a second row rather than a leg of the token // transfer: mergeTransactions() consolidates a native entry and a token // transfer that share a hash into one row, which would leave nothing native // to open. 0.25 ETH clears the 100000 gwei dust threshold the default // filters apply, so the row is not silently dropped. const STUB_NATIVE_TX_HASH = "0xe7e0000000000000000000000000000000000000000000000000000000000e7e"; const STUB_NATIVE_BLOCK_NUMBER = STUB_BLOCK_NUMBER - 1; const STUB_NATIVE_VALUE_WEI = "250000000000000000"; // Fixed instant so timeAgo() output is stable across runs. const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z"; const STUB_NATIVE_TX_TIMESTAMP = "2026-01-02T02:03:04.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); function hex(value) { return "0x" + BigInt(value).toString(16); } // A bigint as a 32-byte ABI word. function word(value) { return "0x" + BigInt(value).toString(16).padStart(64, "0"); } // -------------------------------------------------------- dApp fixture // // The origin the EIP-1193 test page is served from, and the page itself. // // It is a fixture like every other one in this file: the route handler // fulfils the navigation from the string below, so the page never comes // from a remote origin and nothing about the dApp round trips leaves the // container. `.test` is reserved by RFC 6761 and has no owner to reach in // the first place; the launch arguments map every host to NOTFOUND anyway. // // What the page deliberately does NOT do is load a provider. window.ethereum // is put there by the shipped manifest's MAIN-world content script, exactly // as it is on any http(s) page a user visits, so what these tests speak to // is the real inpage provider and not a copy the harness wired up. const DAPP_ORIGIN = "https://dapp.e2e.test"; const DAPP_URL = DAPP_ORIGIN + "/"; // Requests are parked rather than awaited. An approval prompt only exists // while its call is in flight, so a test that awaited the promise could // never drive the popup that has to settle it; start() files the promise // under a key and settle() collects it once the prompt has been dealt with. // // The rejection branch records the whole observable shape of the error as it // arrives — name, message, and whether a `code` is present at all as distinct // from its value. EIP-1193 says a user rejection is a ProviderRpcError // carrying code 4001; what the page can actually see is recorded here rather // than assumed, and asserted in run.js. // // The message log is the page's half of the boundary observation: every // AUTISTMASK_* message that crosses between this page and the content // script, in both directions, verbatim. const DAPP_HTML = [ "", '', "", '', "AutistMask e2e dApp", // Inline and empty: without it Chromium asks for /favicon.ico, which // the unstubbed-request guard would report as escaping traffic. '', "", "", "

AutistMask e2e dApp

", "", "", "", ].join("\n"); // ------------------------------------------------------------ fee fixture // // The confirmation screen carries two different numbers for the same // transaction and may gate on only one of them: // // reserve = gasLimit * maxFeePerGas — what a node requires to be // available for a type-2 transaction, and what the spend gate // must use. // estimate = gasLimit * gasPrice — what the transfer is expected to // actually cost. Display only. // // Issue #154 was the gate reading the smaller of the two. ethers derives // maxFeePerGas as baseFeePerGas * 2 + maxPriorityFeePerGas, so the numbers // below put the reserve at very nearly twice the estimate. That gap is the // entire point of these values: it leaves room for a send that an // estimate-based gate accepts and a reserve-based gate refuses, which is // what lets the ConfirmTx tests tell the two apart at all. Collapse the gap // — by dropping baseFeePerGas from the block below, say — and those tests // go on passing while asserting nothing. const GAS_LIMIT = 21000n; const BASE_FEE_WEI = 100000000000n; // 100 gwei const PRIORITY_FEE_WEI = 1000000000n; // 1 gwei const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI; // 101 gwei const MAX_FEE_WEI = BASE_FEE_WEI * 2n + PRIORITY_FEE_WEI; // 201 gwei const FEE_ESTIMATE_WEI = GAS_LIMIT * GAS_PRICE_WEI; // 0.002121 ETH const FEE_RESERVE_WEI = GAS_LIMIT * MAX_FEE_WEI; // 0.004221 ETH const RPC_RESULTS = { eth_chainId: "0x1", net_version: "1", eth_blockNumber: "0x1406f40", eth_getBalance: "0x0", eth_call: ZERO_WORD, eth_getCode: "0x", eth_gasPrice: hex(GAS_PRICE_WEI), eth_estimateGas: hex(GAS_LIMIT), eth_getTransactionCount: "0x0", eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI), // "not mined yet", which is what a node answers for a transaction it has // only just accepted. The wait screen the dApp transaction approval hands // off to polls this every 10 seconds; leaving it unstubbed would report // the poll as escaping traffic the moment a test outlived one tick. eth_getTransactionReceipt: null, }; // The "latest" block, which ethers' getFeeData() reads baseFeePerGas from // to derive maxFeePerGas. Without it every fee is a legacy gasPrice, the // reserve and the estimate collapse to the same number, and the gate tests // stop being able to distinguish them. function latestBlock() { return { hash: "0x" + "11".repeat(32), parentHash: "0x" + "22".repeat(32), number: hex(STUB_BLOCK_NUMBER), timestamp: hex(1767326645), nonce: "0x0000000000000000", difficulty: "0x0", gasLimit: "0x1c9c380", gasUsed: "0xf4240", miner: STUB_COUNTERPARTY, extraData: "0x", baseFeePerGas: hex(BASE_FEE_WEI), transactions: [], }; } // keccak("decimals()")[0:4]. const SELECTOR_DECIMALS = "0x313ce567"; // Every eth_call still answers with a zero word except decimals() on the // stub token. ethers reads that before it can encode an ERC-20 transfer, // and a zero there makes parseUnits() reject any fractional amount — so the // ERC-20 confirmation path would fail its gas estimate for a reason that // has nothing to do with what is being tested. function ethCallResult(req) { const call = Array.isArray(req.params) ? req.params[0] : null; if (!call || typeof call !== "object") return ZERO_WORD; const data = String(call.data || call.input || "").toLowerCase(); const to = String(call.to || "").toLowerCase(); if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) { return word(STUB_TOKEN.decimals); } return ZERO_WORD; } 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(), }, ]; } // One received native ETH transfer, in the shape src/shared/transactions.js // parses. to.is_contract is false and there is no method, so parseTx() keeps // it a plain transfer rather than a contract call — which is what makes the // detail screen classify it "Native ETH Transfer" and leave the token // contract row hidden. function nativeTransactionItems(address) { return [ { hash: STUB_NATIVE_TX_HASH, block_number: STUB_NATIVE_BLOCK_NUMBER, timestamp: STUB_NATIVE_TX_TIMESTAMP, from: { hash: STUB_COUNTERPARTY }, to: { hash: address, is_contract: false }, value: STUB_NATIVE_VALUE_WEI, status: "ok", }, ]; } // A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving // this is what puts an ERC-20 in the send screen's token dropdown, which is // the only way the confirmation screen's ERC-20 path can be reached. function tokenBalanceItems() { return [ { value: "1500000", token: tokenObject(), }, ]; } // Full details for either seeded transaction — the detail screen fetches // them for whichever row was opened, and an unstubbed hash would be // reported as escaping traffic. raw_input is "0x" so the calldata decoder // short-circuits; the on-chain detail fields still populate. function transactionDetails(hash) { return { hash: hash, block_number: hash === STUB_NATIVE_TX_HASH ? STUB_NATIVE_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//... path. function blockscoutAddress(pathname) { const m = pathname.match(/\/addresses\/(0x[0-9a-fA-F]{40})\//); return m ? m[1] : null; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // How long a deliberately held reply is allowed to stay held, and how often // the release flag is re-read while it is. const HOLD_POLL_MS = 25; const HOLD_MAX_MS = 30000; // Hold a gas estimate open for as long as the test asks. // // opts.holdGasEstimate is read here rather than captured, so a test flips it // on the same options object the route was registered with — the same // pattern as seedTokenTransfer. This is the only way to observe the // confirmation screen while its estimate is genuinely in flight; sampling // the screen and hoping to win a race against the network would assert // nothing on a slow machine. // // It never gives up quietly. A hold that outlives the bound is reported like // any other harness fault, because a "pending" state that stopped being // pending on its own is a green assertion about the wrong screen. async function awaitRelease(opts, report) { const started = Date.now(); while (opts.holdGasEstimate) { if (Date.now() - started > HOLD_MAX_MS) { report( "held gas estimate was never released after " + HOLD_MAX_MS + "ms", ); return; } await sleep(HOLD_POLL_MS); } } // One JSON-RPC reply. Methods whose answer depends on a fixture a test has // set, or on the call itself, are resolved here; every other method is a // constant in RPC_RESULTS. function rpcReply(req, opts, report) { const envelope = { jsonrpc: "2.0", id: req.id }; if (req.method === "eth_getBalance") { return Object.assign(envelope, { result: opts.ethBalanceWei || RPC_RESULTS.eth_getBalance, }); } if (req.method === "eth_call") { return Object.assign(envelope, { result: ethCallResult(req) }); } if (req.method === "eth_getBlockByNumber") { return Object.assign(envelope, { result: latestBlock() }); } // The end of the dApp transaction round trip: the raw signed transaction // the background hands to the node. It is recorded verbatim so a test can // recover the signer from the exact bytes that were broadcast, rather than // from anything the extension reported about them. // // The reply must be the transaction's real hash. ethers compares the hash // the node returns against the one it computes itself and throws on a // mismatch, so a constant here would fail the broadcast for a reason that // has nothing to do with what is being tested. if (req.method === "eth_sendRawTransaction") { const raw = Array.isArray(req.params) ? req.params[0] : null; let parsed; try { parsed = Transaction.from(raw); } catch { report("eth_sendRawTransaction with an undecodable transaction"); return Object.assign(envelope, { error: { code: -32000, message: "undecodable transaction" }, }); } if (Array.isArray(opts.broadcastTransactions)) { opts.broadcastTransactions.push(raw); } return Object.assign(envelope, { result: parsed.hash }); } if (req.method === "eth_estimateGas" && opts.failGasEstimate) { // A refusal the node itself would produce, not a transport error: // this is the shape the confirmation screen has to turn into // "Unable to estimate" rather than into a fee of zero. return Object.assign(envelope, { error: { code: -32000, message: "e2e fixture: gas required exceeds allowance", }, }); } const result = RPC_RESULTS[req.method]; if (result === undefined) { report("unstubbed RPC method: " + req.method); return Object.assign(envelope, { error: { code: -32601, message: "unstubbed in e2e harness" }, }); } return Object.assign(envelope, { result }); } async function handleRpc(route, postData, opts, 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]; // Anything that is not a JSON-RPC object, or a NON-EMPTY batch of // them, is not RPC at all and must be reported like any other // unrecognised outbound traffic rather than dereferenced. // // The length check is not decoration: every() is vacuously true on an // empty array, so without it a POST with body [] was answered 200 [] // and escaped the guard entirely (issue #187). No real batch is empty, // so nothing legitimate is caught by it. // // Two distinct paths land a non-RPC body here, and neither is an // empty-string special case. playwright-core's postData() is // `buffer.toString("utf-8") || null`, so an absent or empty body // decodes to null, JSON.parse("null") yields null, and the type guard // below reports it. A binary body is instead decoded LOSSILY into // mojibake — not null — which is not valid JSON, so the catch above // reports that one. Both end up reported; only the route differs. if ( payload === null || typeof payload !== "object" || batch.length === 0 || !batch.every((req) => req !== null && typeof req === "object") ) { report("unstubbed request: POST " + route.request().url()); return route.abort(); } if (batch.some((req) => req.method === "eth_estimateGas")) { await awaitRelease(opts, report); } const replies = batch.map((req) => rpcReply(req, opts, report)); return jsonResponse(route, Array.isArray(payload) ? replies : replies[0]); } const TRACE_TRUE = ["1", "true", "yes", "on"]; const TRACE_FALSE = ["", "0", "false", "no", "off"]; // Whether E2E_TRACE_NETWORK asks for the request trace. // // A set-but-unrecognised value is a hard error rather than a quiet // "off": E2E_TRACE_NETWORK=true asking for a trace and getting silence // is the operator being lied to about what the harness is doing, which // is the whole failure mode this suite exists to eliminate. Refusing to // guess costs one line and one obvious error message. function traceEnabled(raw) { if (raw === undefined || raw === null) return false; const v = String(raw).trim().toLowerCase(); if (TRACE_TRUE.includes(v)) return true; if (TRACE_FALSE.includes(v)) return false; throw new Error( "E2E_TRACE_NETWORK is set to " + JSON.stringify(String(raw)) + ", which is not a recognised on/off value. Use one of " + TRACE_TRUE.join(", ") + " to enable the request trace, or one of " + TRACE_FALSE.slice(1).join(", ") + " to disable it. Refusing to guess: a diagnostic that silently " + "does nothing is worse than one that is not there", ); } /** * 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. * @param {boolean} [opts.seedNativeTransfer] serve the stubbed native ETH * transfer, read at request time like seedTokenTransfer. Without it the * normal-transactions endpoint answers with an empty list, so there is no * non-ERC-20 row to open. * @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20 * holding, which is what makes the token reachable from the send screen. * @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance; * defaults to zero, which is what every test that predates the funded * fixture expects. * @param {boolean} [opts.failGasEstimate] answer eth_estimateGas with a * node-side refusal. * @param {boolean} [opts.holdGasEstimate] hold every batch containing an * eth_estimateGas until this is cleared again. * @param {string[]} [opts.broadcastTransactions] every raw signed * transaction handed to eth_sendRawTransaction, appended in order. * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * Promise}>} */ async function installNetworkStubs(ctx, opts) { const report = opts.report; // First request seen that originated in a service worker, and the // resolver waiting for it. This is what proves worker interception is // actually in force; see waitForServiceWorkerTraffic below. let firstWorkerRequest = null; let announceWorkerRequest = null; // E2E_TRACE_NETWORK=1 prints every request that reaches this handler, // tagged [sw] when it originated in the background service worker. // It exists so the isolation claim above can be re-checked by anyone // in one command, without editing files: the phishing blocklist fetch // showing up with an [sw] tag is the proof that the worker really is // intercepted and that the raw.githubusercontent.com stub below is // live code rather than decoration. const trace = traceEnabled(process.env.E2E_TRACE_NETWORK); // 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; const fromWorker = !!req.serviceWorker(); if (fromWorker && !firstWorkerRequest) { firstWorkerRequest = req.method() + " " + req.url(); if (announceWorkerRequest) announceWorkerRequest(firstWorkerRequest); } if (trace) { const origin = fromWorker ? "[sw] " : "[page] "; console.log("# routed " + origin + req.method() + " " + req.url()); } // JSON-RPC endpoint (any host): a POST with a JSON-RPC body. if (req.method() === "POST") { return handleRpc(route, req.postData(), opts, report); } // The local EIP-1193 test page. Served from here so the dApp round // trips run against a real http(s) origin — which is what makes the // shipped content scripts inject at all — without any remote origin // being involved. if (url.origin === DAPP_ORIGIN && p === "/") { return route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", body: DAPP_HTML, }); } // Blockscout v2 if (p.includes("/api/v2/")) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) { const addr = blockscoutAddress(p); return jsonResponse(route, { items: opts.seedNativeTransfer && addr ? nativeTransactionItems(addr) : [], }); } 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, opts.seedTokenBalance ? tokenBalanceItems() : [], ); } for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) { if (p.endsWith("/transactions/" + hash)) { return jsonResponse(route, transactionDetails(hash)); } } } // 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: "", }); } report("unstubbed request: " + req.method() + " " + req.url()); return route.abort(); }); return { /** * Resolve with the first service-worker-originated request this * handler saw, or null if none arrives within `ms`. * * The background worker fetches the phishing blocklist at * startup, unconditionally, within about a second of the context * coming up — so under working interception this resolves almost * immediately. Nothing arriving means worker traffic is bypassing * the handler entirely and going to the real internet, which the * caller turns into a hard failure of the whole suite. */ waitForServiceWorkerTraffic(ms) { if (firstWorkerRequest) return Promise.resolve(firstWorkerRequest); return new Promise((resolve) => { const timer = setTimeout(() => { announceWorkerRequest = null; resolve(null); }, ms); announceWorkerRequest = (req) => { clearTimeout(timer); announceWorkerRequest = null; resolve(req); }; }); }, }; } module.exports = { installNetworkStubs, DAPP_ORIGIN, DAPP_URL, FEE_ESTIMATE_WEI, FEE_RESERVE_WEI, STUB_COUNTERPARTY, STUB_NATIVE_TX_HASH, STUB_NATIVE_VALUE_WEI, STUB_TOKEN, STUB_TX_HASH, };