refactor: one shared extension-API module, and drive the dApp flows on Firefox (closes #153)
All checks were successful
check / check (push) Successful in 28s
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.
This commit is contained in:
238
tests/e2e/firefox/dapp.js
Normal file
238
tests/e2e/firefox/dapp.js
Normal file
@@ -0,0 +1,238 @@
|
||||
// A loopback dApp origin and stub Ethereum node for the Firefox suite.
|
||||
//
|
||||
// The Firefox container runs with --network none, and the harness note in
|
||||
// driver.js records the consequence: with no http:// origin in reach, no
|
||||
// content script was ever injected, so content-script behaviour was
|
||||
// UNVERIFIED and the dApp flows could not be driven at all.
|
||||
//
|
||||
// --network none removes every interface except loopback, and loopback is
|
||||
// enough. This serves the page and the JSON-RPC endpoint from 127.0.0.1
|
||||
// inside the same container Firefox runs in, so the dApp round trips execute
|
||||
// against a real http:// origin and the run stays as offline as it was: the
|
||||
// only reachable peer is this process.
|
||||
//
|
||||
// The page itself is not written twice. DAPP_HTML comes from the Chrome
|
||||
// suite's fixture, so both harnesses drive the same __dapp API and the same
|
||||
// message log.
|
||||
//
|
||||
// Unlike driver.js this file does use ethers, and it has to: the node has to
|
||||
// answer eth_sendRawTransaction with the hash ethers computes for the
|
||||
// artifact it was handed, or provider.broadcastTransaction() refuses the
|
||||
// answer, and the suite recovers signatures itself rather than believing the
|
||||
// extension's own verdict.
|
||||
|
||||
"use strict";
|
||||
|
||||
const http = require("http");
|
||||
|
||||
const { Transaction } = require("ethers");
|
||||
|
||||
const { DAPP_HTML } = require("../network");
|
||||
|
||||
// The same fee shape the Chrome suite uses, for the same reason: it has to
|
||||
// pass the ceilings in src/shared/approvalVerify.js and it has to leave the
|
||||
// reserve and the estimate distinguishable.
|
||||
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;
|
||||
|
||||
const STUB_BLOCK_NUMBER = 21000000;
|
||||
|
||||
// A 32-byte zero word, returned for every eth_call. It is what makes ethers'
|
||||
// ENS reverse lookup resolve to "no resolver set" instead of throwing, and a
|
||||
// throw there reaches the console through src/shared/log.js, which fails the
|
||||
// run on its own.
|
||||
const ZERO_WORD = "0x" + "0".repeat(64);
|
||||
|
||||
// One ETH, so the popup's balance lines render something and the wallet does
|
||||
// not look empty on the approval screen.
|
||||
const STUB_BALANCE_WEI = 10n ** 18n;
|
||||
|
||||
function hex(value) {
|
||||
return "0x" + BigInt(value).toString(16);
|
||||
}
|
||||
|
||||
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: "0xc0ffee0000000000000000000000000000c0ffee",
|
||||
extraData: "0x",
|
||||
baseFeePerGas: hex(BASE_FEE_WEI),
|
||||
transactions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const RPC_RESULTS = {
|
||||
eth_chainId: "0x1",
|
||||
net_version: "1",
|
||||
eth_blockNumber: hex(STUB_BLOCK_NUMBER),
|
||||
eth_getBalance: hex(STUB_BALANCE_WEI),
|
||||
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),
|
||||
// "accepted but not mined", which is what a node says about a transaction
|
||||
// it has only just taken. The wait screen the approval hands off to polls
|
||||
// this for the rest of the run.
|
||||
eth_getTransactionReceipt: null,
|
||||
web3_clientVersion: "autistmask-e2e-firefox/0",
|
||||
};
|
||||
|
||||
// Answer one JSON-RPC call. `broadcast` collects every raw transaction that
|
||||
// reached this node, which is what the transaction assertions are made
|
||||
// against — the artifact as the node saw it, never as the extension described
|
||||
// it.
|
||||
function rpcResult(req, state) {
|
||||
const method = req.method;
|
||||
|
||||
if (method === "eth_sendRawTransaction") {
|
||||
const raw = req.params && req.params[0];
|
||||
state.broadcast.push(raw);
|
||||
// ethers checks the hash it is given against the hash it computes for
|
||||
// the artifact it sent, so this cannot be a fixed string.
|
||||
return Transaction.from(raw).hash;
|
||||
}
|
||||
|
||||
if (method === "eth_getBlockByNumber" || method === "eth_getBlockByHash") {
|
||||
return latestBlock();
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) {
|
||||
return RPC_RESULTS[method];
|
||||
}
|
||||
|
||||
// Never a silent default. An unstubbed method answered with null looks
|
||||
// like a working node returning nothing, and the assertion downstream
|
||||
// fails somewhere unrelated.
|
||||
state.unstubbed.push(method);
|
||||
throw new Error("no fixture for JSON-RPC method " + method);
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => resolve(body));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function handleRpcBody(body, state) {
|
||||
const parsed = JSON.parse(body);
|
||||
const answer = (req) => {
|
||||
try {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
result: rpcResult(req, state),
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: req.id,
|
||||
error: { code: -32601, message: e.message },
|
||||
};
|
||||
}
|
||||
};
|
||||
return Array.isArray(parsed) ? parsed.map(answer) : answer(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the dApp page and the stub node on loopback.
|
||||
*
|
||||
* @returns {Promise<Object>} the running fixture: `url` and `origin` of the
|
||||
* page, `rpcUrl` for the extension's rpcUrl setting, `broadcast` (the raw
|
||||
* transactions the node received, in order), `unstubbed` (JSON-RPC methods
|
||||
* nothing answered) and `close()`.
|
||||
*/
|
||||
async function startDappServer() {
|
||||
const state = { broadcast: [], unstubbed: [], requests: [] };
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
state.requests.push(req.method + " " + url.pathname);
|
||||
|
||||
if (url.pathname === "/rpc" && req.method === "POST") {
|
||||
readBody(req)
|
||||
.then((body) => {
|
||||
const payload = JSON.stringify(handleRpcBody(body, state));
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
// The extension fetches this from its background
|
||||
// page, whose origin is moz-extension://. Without CORS
|
||||
// the fetch fails and every transaction assertion
|
||||
// fails for a reason that has nothing to do with the
|
||||
// wallet.
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
res.end(payload);
|
||||
})
|
||||
.catch((e) => {
|
||||
res.writeHead(500, { "Content-Type": "text/plain" });
|
||||
res.end(String(e && e.message));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/") {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(DAPP_HTML);
|
||||
return;
|
||||
}
|
||||
|
||||
// An empty favicon rather than a 404: a 404 is a page error in
|
||||
// Firefox's console under some settings, and the suite fails the run
|
||||
// on those.
|
||||
if (url.pathname === "/favicon.ico") {
|
||||
res.writeHead(200, { "Content-Type": "image/x-icon" });
|
||||
res.end("");
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("not found");
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.on("error", reject);
|
||||
// Port 0: this host runs many sessions at once, and a fixed port is a
|
||||
// guaranteed collision rather than a possible one.
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
|
||||
const { port } = server.address();
|
||||
const origin = "http://127.0.0.1:" + port;
|
||||
|
||||
return {
|
||||
origin,
|
||||
url: origin + "/",
|
||||
rpcUrl: origin + "/rpc",
|
||||
broadcast: state.broadcast,
|
||||
unstubbed: state.unstubbed,
|
||||
requests: state.requests,
|
||||
close: () =>
|
||||
new Promise((resolve) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GAS_LIMIT,
|
||||
GAS_PRICE_WEI,
|
||||
STUB_BALANCE_WEI,
|
||||
startDappServer,
|
||||
};
|
||||
Reference in New Issue
Block a user