Files
AutistMask/tests/e2e/network.js
clawbot a13862d991
All checks were successful
check / check (push) Successful in 19s
test: make e2e error attribution total, and fix the README canary text
The error collector had a window API (mark/since) and twice a record fell
outside somebody's window and was silently dropped, producing a green run
that proved nothing: first the mark started after test 1, discarding
everything recorded during launch; then the tail after the final test was
never read at all, so a request escaping the fixtures at the end of the
last test reported 5/5 passed and exit 0.

Rather than patch a second boundary and invite a third, the window
concept is gone. ErrorCollector exposes only take(), which always drains
everything outstanding, so successive takes partition the whole record
stream with no gaps, and seal(), which closes the stream at the end of
the run and routes stragglers straight to a failure. Attribution is
total by construction: launch through test 1 goes to test 1, each
subsequent interval to the test that ends it, the tail to the suite.

The tail also needs to exist before it can be drained. A request a test
fires without awaiting reaches the route handler about 10ms after that
test's function resolves, and closing the context does not wait for it,
so with no window at all it died unobserved. The run now keeps
collecting for a bounded 1.5s after the last test before teardown.

Also:

- README described a canary that was built, found to kill the service
  worker, and deleted. Replaced with what actually runs: the harness
  waits for the background worker's own startup blocklist fetch to reach
  the route handler and aborts if it does not. Documents
  --host-resolver-rules=MAP * ~NOTFOUND as defence in depth.
- The canary's failure message asserted traffic was escaping to the real
  internet and blamed the -e flag. It cannot distinguish that from a lost
  startup race, so it now states what was observed and lists both causes.
- E2E_TRACE_NETWORK was compared strictly to "1", so E2E_TRACE_NETWORK=true
  silently did nothing. Recognised on/off values are accepted and anything
  else is a hard error rather than a quiet default.
- The measured margin that makes the canary sound is route install at
  11-23ms against the worker fetch at 525-883ms, not the 30s timeout
  slack the comment cited.
2026-08-09 15:50:45 +00:00

318 lines
12 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]);
}
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.
* @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(), 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,
};