All checks were successful
check / check (push) Successful in 43s
ctx.route() does not see requests made by the background service worker unless Playwright is run with PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, so the phishing blocklist fetch that src/background/index.js issues at worker startup was reaching raw.githubusercontent.com on the real internet on every run. phishingDomains.js swallows fetch failures, so nothing surfaced it, and the raw.githubusercontent.com stub in tests/e2e/network.js was unreachable code that made the gap look covered. script/test-e2e now sets the flag, with a comment recording what to do if a future Playwright drops it. The flag being experimental is not taken on trust: launch() waits for the worker's own startup request to arrive in the route handler and refuses to run the suite if it never does, so escaping traffic fails the run instead of passing unnoticed. Chrome is additionally started with --host-resolver-rules=MAP * ~NOTFOUND, so anything that does slip past interception cannot reach a real host. Also: errors and unstubbed requests recorded during launch are attributed to the first test rather than discarded, a suite that registers no tests now fails instead of exiting 0, a failure after the browser is up tears the context down instead of hanging the process, E2E_TRACE_NETWORK=1 prints every routed request tagged [sw] or [page], and the dead exports in harness.js and network.js are gone.
291 lines
11 KiB
JavaScript
291 lines
11 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";
|
|
|
|
// 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.
|
|
* @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 = process.env.E2E_TRACE_NETWORK === "1";
|
|
|
|
// 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(), 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();
|
|
});
|
|
|
|
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,
|
|
STUB_TOKEN,
|
|
STUB_TX_HASH,
|
|
};
|