All checks were successful
check / check (push) Successful in 33s
The suite asserted that the two screens those issues broke now open without throwing, which is narrower than their definition of done. The four remaining items are asserted here, additively; nothing existing was restructured. Back navigation out of Add Token is checked against the persisted navigation stack, read from extension storage, as a delta: the round trip Home -> AddressDetail -> AddToken -> Back -> Back must leave the stack exactly as it found it. A stale entry is invisible on screen until the user presses Back one time too many, which is precisely the second-order damage of #150, so the stack rather than the visible view is what gets asserted. Stating it as a delta keeps it independent of whatever depth earlier tests leave behind. The quick-pick test clicks a button and requires the address field to hold that button's contract address; the old assertion only counted the buttons rendered. The native ETH detail path needed a fixture: the normal-transactions endpoint answered with an empty list unconditionally, so there was no non-ERC-20 row to open at all. seedNativeTransfer serves one, and the detail screen must show the native type, the value, the raw wei quantity and no token contract row - the row whose branch is where a regression of the non-ERC-20 case would land. Tap-to-copy reads the real clipboard back rather than watching the handler run, after seeding a sentinel so an untouched clipboard cannot pass. Clipboard permissions are granted context-wide because an origin-scoped grant is refused for chrome-extension: URLs. Each of the four was demonstrated failing against a deliberately broken build; the captured output is in the pull request.
699 lines
28 KiB
JavaScript
699 lines
28 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;
|
|
|
|
// 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 = [
|
|
"<!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(),
|
|
},
|
|
];
|
|
}
|
|
|
|
// 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/<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.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<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)) {
|
|
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: "<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_ORIGIN,
|
|
DAPP_URL,
|
|
FEE_ESTIMATE_WEI,
|
|
FEE_RESERVE_WEI,
|
|
STUB_COUNTERPARTY,
|
|
STUB_NATIVE_TX_HASH,
|
|
STUB_NATIVE_VALUE_WEI,
|
|
STUB_TOKEN,
|
|
STUB_TX_HASH,
|
|
};
|