All checks were successful
check / check (push) Successful in 28s
Every call site that touched `browser.*` or `chrome.*` now goes through `src/shared/browserApi.js`, the only file in the tree that names either. It exposes lazily-resolved namespace handles for events and synchronous methods, and promise-returning wrappers for everything that is callback-shaped on Chrome. Callers await; `runtime.lastError` is gone, folded into the rejection the wrapper produces on the Chrome path. The Firefox suite gains the four dApp round trips the issue's definition of done asks for — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001 — driven through the real content script, background page and approval windows. `--network none` was thought to rule that out because it leaves no `http://` origin to inject into; loopback survives it, so the page and a JSON-RPC node are served from 127.0.0.1 inside the container and the run still reaches nothing but itself. That harness refutes the premise it was built to verify. On Firefox 153.0.3, `browser.*` honours a trailing Chrome-style callback and does populate `runtime.lastError`, both measured directly, and all four flows pass against the unconverted code. So this is a uniformity and coverage change, not a repair of a broken target; the PR records the measurement in full. One real defect is fixed on the way past: the window id written back into a pending approval after `windows.create()` was unguarded, so an approval settled during the open — an address switch will do it — dereferenced a deleted entry.
654 lines
26 KiB
JavaScript
654 lines
26 KiB
JavaScript
// 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;
|
|
|
|
// 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);
|
|
|
|
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.
|
|
//
|
|
// DAPP_HTML below is exported and served verbatim by the Firefox suite too
|
|
// (tests/e2e/firefox/dapp.js), from a loopback origin rather than through a
|
|
// route handler. The two suites drive different browsers over different
|
|
// protocols, but the page they drive — the __dapp API, the message log — is
|
|
// one fixture, so an assertion written against it means the same thing on
|
|
// both.
|
|
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 = [
|
|
"<!doctype html>",
|
|
'<html lang="en">',
|
|
"<head>",
|
|
'<meta charset="utf-8">',
|
|
"<title>AutistMask e2e dApp</title>",
|
|
// Inline and empty: without it Chromium asks for /favicon.ico, which
|
|
// the unstubbed-request guard would report as escaping traffic.
|
|
'<link rel="icon" href="data:,">',
|
|
"</head>",
|
|
"<body>",
|
|
"<h1>AutistMask e2e dApp</h1>",
|
|
"<script>",
|
|
"window.__dapp = {",
|
|
" messages: [],",
|
|
" calls: {},",
|
|
" start: function (key, method, params) {",
|
|
" window.__dapp.calls[key] = window.ethereum",
|
|
" .request({ method: method, params: params })",
|
|
" .then(",
|
|
" function (result) {",
|
|
" return { settled: 'resolved', result: result };",
|
|
" },",
|
|
" function (error) {",
|
|
" return {",
|
|
" settled: 'rejected',",
|
|
" message: String((error && error.message) || error),",
|
|
" name: error ? error.name : undefined,",
|
|
" hasCode: !!error && 'code' in Object(error),",
|
|
" code: error ? error.code : undefined,",
|
|
" };",
|
|
" },",
|
|
" );",
|
|
" },",
|
|
" settle: function (key) {",
|
|
" return window.__dapp.calls[key];",
|
|
" },",
|
|
"};",
|
|
"window.addEventListener('message', function (event) {",
|
|
" if (event.source !== window) return;",
|
|
" var d = event.data;",
|
|
" if (!d || typeof d.type !== 'string') return;",
|
|
" if (d.type.indexOf('AUTISTMASK') !== 0) return;",
|
|
" window.__dapp.messages.push(d);",
|
|
"});",
|
|
"</script>",
|
|
"</body>",
|
|
"</html>",
|
|
].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(),
|
|
},
|
|
];
|
|
}
|
|
|
|
// 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 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 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.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<string|null>}>}
|
|
*/
|
|
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)) {
|
|
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,
|
|
opts.seedTokenBalance ? tokenBalanceItems() : [],
|
|
);
|
|
}
|
|
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();
|
|
});
|
|
|
|
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_HTML,
|
|
DAPP_ORIGIN,
|
|
DAPP_URL,
|
|
FEE_ESTIMATE_WEI,
|
|
FEE_RESERVE_WEI,
|
|
STUB_COUNTERPARTY,
|
|
STUB_TOKEN,
|
|
STUB_TX_HASH,
|
|
};
|