Files
AutistMask/tests/e2e/firefox/run.js
T
clawbot add11e57de
check / check (push) Successful in 1m4s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 33s
security: announce a fresh EIP-6963 provider UUID per page load (closes #398)
The provider UUID was generated once, kept in extension storage and
announced to every page on every load, so any site could read it as a
stable identifier for the install across sites and browser restarts.

inpage.js now generates one UUID per page load, shared by every
announcement in that load, and stores nothing. The eip6963Uuid storage
key and the AUTISTMASK_PROVIDER_UUID content-script message are removed.
The key was never part of the versioned autistmask profile, so the state
schema is untouched. Two inpage.js message listeners lose the leftover
name onUuid.

The test posts each load the same stored UUID the way the old content
script did, and asserts that no load announces it and that two loads
announce different UUIDs.

Model: opus-5-5
2026-10-03 16:26:39 +02:00

956 lines
36 KiB
JavaScript

// Firefox end-to-end suite: drives the real popup in a real Firefox with
// the unpacked MV2 build installed as a temporary add-on, and fails the run
// on any uncaught error coming from an extension source.
//
// Run via script/test-e2e-firefox, which builds dist/firefox/ and the pinned
// container. The extension directory is the one argument.
//
// node tests/e2e/firefox/run.js [dist/firefox]
//
// Deliberately not part of script/check, and deliberately not named
// *.test.js: REPO_POLICIES.md caps make test at 20 seconds and a browser
// suite does not fit.
//
// This shares no driver layer with the Chrome suite in tests/e2e/, and the
// UI steps below are written twice on purpose. Chrome runs on Playwright,
// which cannot see extension-page errors in Firefox at all (see the BiDi
// note in driver.js), so the two backends have no common substrate to
// abstract over. Duplicated steps do not pay for a shim; revisit if this
// suite grows to where they do. What IS shared is the dApp page fixture
// itself — DAPP_HTML, served here from loopback by dapp.js — so an assertion
// about the __dapp API means the same thing on both browsers.
//
// The dApp steps need an http:// origin, which --network none was thought to
// rule out. It does not: loopback survives it, so the page and the stub node
// are served from 127.0.0.1 inside the container and the run reaches nothing
// but this process. See tests/e2e/firefox/dapp.js.
//
// LIMITATION, and the difference from the Chrome suite worth knowing: error
// capture here is POLL-BASED, not event-streamed. The console service is
// drained at each step boundary, so an error is attributed to the step it
// was drained after, never to a moment within that step. What is drained
// covers the whole run from add-on install to the last drain below, which
// lands ~1.5s after the last step returns (500ms settle + 1000ms sleep +
// two drain round trips). That cut-off jitters run to run: three runs of
// throws at fixed offsets reported everything to +1.5s and one of them
// also +1.6s, and past it the browser is torn down first. Inside the
// window there is no race — the drain reads and clears in one chrome
// round trip — but there is a capacity limit: nsIConsoleService keeps
// only the newest 250 messages, so 400 throws in one step report as
// exactly 250. A clean run peaks at 4 of 250, so that is headroom today
// and not a guarantee for a step that logs heavily. The Chrome harness
// receives pageerror events as they happen and can say more. Do not read
// a green Firefox run as the same claim.
"use strict";
const fs = require("fs");
const path = require("path");
const {
Transaction,
formatEther,
getAddress,
getBytes,
hexlify,
parseEther,
toQuantity,
toUtf8Bytes,
verifyMessage,
} = require("ethers");
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network");
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
const PASSWORD = "e2e-harness-password";
// Firefox installs the add-on and starts its background page asynchronously
// after the install call returns. Nothing observable marks the end of that,
// so the popup's own first render is the signal we wait on instead.
const STEP_TIMEOUT_MS = 120000;
const steps = [];
function step(name, fn) {
steps.push({ name, fn });
}
function assert(cond, message) {
if (!cond) throw new Error(message);
}
function withTimeout(promise, name) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
name + " timed out after " + STEP_TIMEOUT_MS + "ms",
),
),
STEP_TIMEOUT_MS,
);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
// ------------------------------------------------------------- steps
step("popup loads and reaches the welcome view", async (env) => {
const d = env.driver;
await d.navigate(POPUP_URL);
await d.waitVisible("#view-welcome", STEP_TIMEOUT_MS);
const title = await d.title();
assert(title === "AutistMask", "unexpected popup title: " + title);
});
step("wallet creation through the UI reaches the main view", async (env) => {
const d = env.driver;
await d.click("#btn-welcome-add");
await d.waitVisible("#view-add-wallet");
await d.click("#btn-generate-phrase");
await d.waitFor(
"a generated recovery phrase of at least 12 words",
`const el = document.getElementById("wallet-mnemonic");
return !!el && el.value.trim().split(/\\s+/).length >= 12;`,
);
env.phrase = (await d.value("#wallet-mnemonic")).trim();
await d.fill("#add-wallet-password", PASSWORD);
await d.fill("#add-wallet-password-confirm", PASSWORD);
await d.click("#btn-add-wallet-confirm");
// Argon2id under libsodium, for real, so this is the slow one.
await d.waitVisible("#view-main", STEP_TIMEOUT_MS);
assert(
env.phrase.split(/\s+/).length >= 12,
"wallet creation did not yield a recovery phrase",
);
const addrs = await d.count("#wallet-list .btn-addr-info");
assert(addrs > 0, "no addresses rendered in the wallet list");
});
step("add token screen opens from address detail", async (env) => {
const d = env.driver;
if (!(await d.isVisible("#view-address"))) {
await d.waitVisible("#view-main");
await d.click("#wallet-list .btn-addr-info");
}
await d.waitVisible("#view-address");
await d.click("#btn-add-token");
// Reported with the view it actually stayed on: a screen that does
// not change is the symptom a missing import produces, and naming
// the screen is what makes that diagnosable.
try {
await d.waitVisible("#view-add-token");
} catch (e) {
throw new Error(
e.message + "; current view is " + (await d.currentView()),
);
}
const picks = await d.count("#common-token-list .common-token");
assert(picks > 0, "no common-token quick-pick buttons rendered");
});
// ------------------------------------------------- the dApp round trips
//
// Everything above drives the popup on its own. From here the page, the
// content script, the inpage provider, the background page and the approval
// window all have to work together — the paths
// https://git.eeqj.de/sneak/AutistMask/issues/153 rewrote, and the ones no
// Firefox test reached before. They are asserted here because nothing else
// covers them on this browser, not because they were broken: these steps
// pass against the pre-refactor callback code too, which is how the issue's
// premise was refuted.
//
// The shape is the Chrome suite's (tests/e2e/run.js, the #183 section) and
// the assertions mean the same things:
//
// - the signature is recovered here, in the runner, from the artifact the
// extension produced, and compared against the address read out of
// extension storage. The background verifies too; these assertions do not
// lean on that, because a test that trusted the wallet's own verdict would
// pass against a wallet that verified nothing.
// - the transaction is asserted against the raw signed transaction that
// reached the stub node, not against anything the extension reported.
//
// What this does NOT cover: a real dApp with real funds against a real
// network. The node is a fixture on loopback.
const SIGN_TEXT = "AutistMask e2e round trip: personal_sign";
const SIGN_HEX = hexlify(toUtf8Bytes(SIGN_TEXT));
const TX_VALUE_ETH = "0.0123";
const TX_VALUE_WEI = parseEther(TX_VALUE_ETH);
// Call data that decodes as nothing, so the screen assertion compares the
// calldata itself rather than a decoder's summary of it.
const TX_DATA = "0xdeadbeef" + "01".repeat(28);
const USER_REJECTION_MESSAGE = "User rejected the request.";
// Read the extension's persisted state, point its rpcUrl at the loopback stub
// node, and hand back the active address. Runs on the popup page, which is
// the one moz-extension:// document the suite has open and therefore the only
// place the storage API is reachable from.
async function pointAtStubNode(d, rpcUrl) {
const outcome = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const rpcUrl = arguments[0];
const api = typeof browser !== "undefined" ? browser : chrome;
Promise.resolve(api.storage.local.get("autistmask"))
.then((r) => {
const s = r.autistmask;
if (!s) throw new Error("the extension has no persisted state");
s.rpcUrl = rpcUrl;
const w = s.wallets && s.wallets[0];
const first = w && w.addresses && w.addresses[0];
const address = s.activeAddress || (first && first.address);
if (!address) throw new Error("the extension holds no address");
return Promise.resolve(api.storage.local.set({ autistmask: s }))
.then(() => done({ address: address }));
})
.catch((e) => done({ error: String((e && e.message) || e) }));`,
[rpcUrl],
);
assert(
outcome && !outcome.error,
"could not point the extension at the stub node: " +
(outcome && outcome.error),
);
return getAddress(outcome.address);
}
// The approval window the background opened. Approvals are raised from an RPC
// call rather than from a user gesture, so the extension opens a real window
// for them, which is an ordinary window handle here.
async function waitForApprovalWindow(d, timeout = 30000) {
const deadline = Date.now() + timeout;
for (;;) {
const handle = await d.findWindow((u) => u.includes("?approval="));
if (handle) return handle;
if (Date.now() > deadline) {
throw new Error(
"the extension opened no approval window within " +
timeout +
"ms",
);
}
await sleep(100);
}
}
function startRequest(d, key, method, params) {
return d.execute(
"window.__dapp.start(arguments[0], arguments[1], arguments[2]);" +
" return true;",
[key, method, params],
);
}
// The settled outcome of a parked request, or {settled:"pending"} if it is
// still outstanding. A bounded wait rather than a bare await: "returns a
// rejection rather than hanging" is one of the things under test, and an
// await would report a hang as a step timeout with no indication of which
// call never settled.
function settleRequest(d, key, timeout = 45000) {
return d.executeAsync(
`const done = arguments[arguments.length - 1];
const key = arguments[0];
const timeout = arguments[1];
Promise.race([
window.__dapp.settle(key),
new Promise((r) => setTimeout(() => r({ settled: "pending" }), timeout)),
]).then(done, (e) => done({ settled: "error", message: String(e) }));`,
[key, timeout],
);
}
// Every AUTISTMASK_* message that has crossed between the page and the
// content script. This is the boundary half of the rejection assertion: the
// code has to be on the wire as well as on the Error the page catches, so a
// pass cannot come from the provider inventing one.
function dappMessages(d, type) {
return d.execute(
// `want` is bound outside the callback deliberately: inside it,
// arguments[0] is the message being tested, not the script argument,
// and the filter silently matches nothing.
"var want = arguments[0];" +
" return window.__dapp.messages.filter(function (m) {" +
" return !want || m.type === want; });",
[type || null],
);
}
async function lastResponseError(d) {
const responses = await dappMessages(d, "AUTISTMASK_RESPONSE");
const last = responses[responses.length - 1];
assert(last, "the page received no AUTISTMASK_RESPONSE at all");
return last.error || null;
}
// A rejected prompt, asserted at both ends: the page's promise rejected
// rather than hanging or resolving, and the response that crossed the
// boundary carried EIP-1193 code 4001.
async function assertUserRejection(d, key, label) {
const outcome = await settleRequest(d, key);
assert(
outcome.settled !== "pending",
label + " never settled: the rejected prompt left the page hanging",
);
assert(
outcome.settled === "rejected",
label + " resolved instead of rejecting: " + JSON.stringify(outcome),
);
assert(
outcome.message === USER_REJECTION_MESSAGE,
label + " rejected with the wrong message: " + outcome.message,
);
const error = await lastResponseError(d);
assert(
error && error.code === 4001,
label +
" did not carry EIP-1193 code 4001 across the boundary: " +
JSON.stringify(error),
);
assert(
outcome.hasCode,
label +
" reached the page as an error with no code property at all, so a " +
"dApp cannot tell the user's refusal from a failure: " +
JSON.stringify(outcome),
);
assert(
outcome.code === 4001,
label +
" reached the page with code " +
JSON.stringify(outcome.code) +
" rather than EIP-1193 4001",
);
assert(
outcome.name === "ProviderRpcError",
label +
" reached the page as " +
JSON.stringify(outcome.name) +
" rather than an EIP-1193 ProviderRpcError",
);
console.log(
"# " +
label +
": code 4001 on the wire and on the page's " +
outcome.name,
);
}
step("the loopback dApp page gets the real inpage provider", async (env) => {
const d = env.driver;
// The popup is still the current window; point the extension at the stub
// node from there, then reload it so its in-memory copy of the state
// carries the new rpcUrl and cannot save the old one back over it.
env.address = await pointAtStubNode(d, env.server.rpcUrl);
await d.navigate(POPUP_URL);
await d.waitVisible("#view-main", STEP_TIMEOUT_MS);
env.popupWindow = await d.currentWindow();
env.dappWindow = await d.newWindow("tab");
await d.switchToWindow(env.dappWindow);
await d.navigate(env.server.url);
// window.ethereum is not the fixture's doing — it is the shipped content
// script, injected into a real http:// origin. Waiting for it is waiting
// for the real provider to have installed itself.
await d.waitFor(
"the injected EIP-1193 provider and the test page API",
"return !!window.ethereum && !!window.__dapp;",
[],
STEP_TIMEOUT_MS,
);
// EIP-6963, asked of the provider itself. The announcement carries a
// UUIDv4 inpage.js generates fresh for this page load (nothing persists
// it — see issue #398) and has to name this extension and hand back the
// very object on window.ethereum.
const announced = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const onAnnounce = (e) => {
window.removeEventListener("eip6963:announceProvider", onAnnounce);
done({
rdns: e.detail.info.rdns,
uuid: e.detail.info.uuid,
isWindowEthereum: e.detail.provider === window.ethereum,
});
};
window.addEventListener("eip6963:announceProvider", onAnnounce);
window.dispatchEvent(new Event("eip6963:requestProvider"));
setTimeout(() => done(null), 15000);`,
);
assert(announced, "the provider announced itself to no EIP-6963 request");
assert(
announced.rdns === "berlin.sneak.autistmask",
"the announced provider is not this extension: " +
JSON.stringify(announced),
);
assert(
announced.isWindowEthereum,
"the announced provider is not the object on window.ethereum",
);
assert(
typeof announced.uuid === "string" && announced.uuid.length === 36,
"the announcement carries no provider uuid: " +
JSON.stringify(announced.uuid),
);
// A full page -> content script -> background round trip that needs no
// approval, so the relay is proven before any prompt is driven. This is
// call site 2, the one that used to fail for every window.ethereum
// request a dApp made.
const chainId = await d.executeAsync(
`const done = arguments[arguments.length - 1];
window.ethereum.request({ method: "eth_chainId" }).then(
(r) => done({ ok: r }),
(e) => done({ err: String((e && e.message) || e) }),
);`,
);
assert(
chainId && chainId.ok === "0x1",
"eth_chainId did not round trip through the extension: " +
JSON.stringify(chainId),
);
console.log(
"# dapp origin " + env.server.origin + " active address " + env.address,
);
});
step(
"eth_requestAccounts approved returns the selected address",
async (env) => {
const d = env.driver;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "accounts", "eth_requestAccounts", []);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-site");
const hostname = await d.text("#approve-hostname");
assert(
hostname === "127.0.0.1",
"the site prompt names the wrong origin: " +
JSON.stringify(hostname),
);
const shown = await d.text("#approve-address");
assert(
shown.toLowerCase().includes(env.address.toLowerCase()),
"the site prompt shows the wrong address: " + JSON.stringify(shown),
);
// Remembered, so the origin stays authorized for the sign and transaction
// steps below.
const checked = await d.execute(
'return document.getElementById("approve-remember").checked;',
);
if (!checked) await d.click("#approve-remember");
await d.click("#btn-approve");
// The approve button closes its own window, so get off it before asking
// the page anything.
await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "accounts");
assert(
outcome.settled === "resolved",
"eth_requestAccounts did not resolve: " + JSON.stringify(outcome),
);
assert(
Array.isArray(outcome.result) && outcome.result.length === 1,
"eth_requestAccounts returned no single account: " +
JSON.stringify(outcome.result),
);
assert(
getAddress(outcome.result[0]) === env.address,
"eth_requestAccounts returned " +
outcome.result[0] +
", not the selected address " +
env.address,
);
},
);
step(
"personal_sign returns a signature that recovers to the address",
async (env) => {
const d = env.driver;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign", "personal_sign", [SIGN_HEX, env.address]);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-sign");
const screen = await d.execute(
`return {
hostname: document.getElementById("approve-sign-hostname").textContent,
type: document.getElementById("approve-sign-type").textContent,
message: document.getElementById("approve-sign-message").textContent,
from: document.getElementById("approve-sign-from").textContent,
};`,
);
assert(
screen.hostname === "127.0.0.1",
"the sign prompt names the wrong origin: " +
JSON.stringify(screen.hostname),
);
assert(
screen.type === "Personal message",
"the sign prompt reports the wrong type: " +
JSON.stringify(screen.type),
);
assert(
screen.message === SIGN_TEXT,
"the sign prompt shows the wrong message: " +
JSON.stringify(screen.message),
);
assert(
screen.from.toLowerCase().includes(env.address.toLowerCase()),
"the sign prompt shows the wrong signing address: " +
JSON.stringify(screen.from),
);
await d.fill("#approve-sign-password", PASSWORD);
await d.click("#btn-approve-sign");
await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "sign");
assert(
outcome.settled === "resolved",
"personal_sign did not resolve: " + JSON.stringify(outcome),
);
const recovered = getAddress(
verifyMessage(getBytes(SIGN_HEX), outcome.result),
);
console.log(
"# personal_sign: recovered=" +
recovered +
" expected=" +
env.address,
);
assert(
recovered === env.address,
"the personal_sign signature recovers to " +
recovered +
", not to the approved address " +
env.address,
);
},
);
step(
"eth_sendTransaction shows the transaction and returns its hash",
async (env) => {
const d = env.driver;
const before = env.server.broadcast.length;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "tx", "eth_sendTransaction", [
{
from: env.address,
to: STUB_COUNTERPARTY,
value: toQuantity(TX_VALUE_WEI),
data: TX_DATA,
},
]);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-tx");
const screen = await d.execute(
`return {
hostname: document.getElementById("approve-tx-hostname").textContent,
from: document.getElementById("approve-tx-from").textContent,
to: document.getElementById("approve-tx-to").textContent,
value: document.getElementById("approve-tx-value").textContent,
data: document.getElementById("approve-tx-data").textContent,
dataShown: !document
.getElementById("approve-tx-data-section")
.classList.contains("hidden"),
};`,
);
assert(
screen.hostname === "127.0.0.1",
"the transaction prompt names the wrong origin: " +
JSON.stringify(screen.hostname),
);
assert(
screen.from.toLowerCase().includes(env.address.toLowerCase()),
"the transaction prompt shows the wrong sender: " +
JSON.stringify(screen.from),
);
assert(
screen.to.toLowerCase().includes(STUB_COUNTERPARTY.toLowerCase()),
"the transaction prompt shows the wrong recipient: " +
JSON.stringify(screen.to),
);
assert(
screen.value.startsWith(TX_VALUE_ETH + " ETH"),
"the transaction prompt shows the wrong value: " +
JSON.stringify(screen.value),
);
assert(
screen.dataShown && screen.data === TX_DATA,
"the transaction prompt does not show the approved call data: " +
JSON.stringify(screen.data),
);
await d.fill("#approve-tx-password", PASSWORD);
await d.click("#btn-approve-tx");
// The approval window hands off to the wait screen rather than closing,
// and the hash it shows is asserted before it is retired: left open it
// polls the stub node for a receipt for the rest of the run.
await d.waitVisible("#view-wait-tx", STEP_TIMEOUT_MS);
const waitHash = await d.text("#wait-tx-hash");
await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "tx");
assert(
outcome.settled === "resolved",
"eth_sendTransaction did not resolve: " + JSON.stringify(outcome),
);
// The artifact as the node saw it, not as the extension described it.
assert(
env.server.broadcast.length === before + 1,
"expected exactly one raw transaction to reach the node, got " +
(env.server.broadcast.length - before),
);
const signed = Transaction.from(
env.server.broadcast[env.server.broadcast.length - 1],
);
console.log(
"# eth_sendTransaction: signer=" +
getAddress(signed.from) +
" to=" +
getAddress(signed.to) +
" value=" +
formatEther(signed.value) +
" chainId=" +
signed.chainId,
);
assert(
getAddress(signed.from) === env.address,
"the broadcast transaction was signed by " +
getAddress(signed.from) +
", not by the approved address " +
env.address,
);
assert(
getAddress(signed.to) === getAddress(STUB_COUNTERPARTY),
"the broadcast transaction goes to " + signed.to,
);
assert(
signed.value === TX_VALUE_WEI,
"the broadcast transaction carries " +
formatEther(signed.value) +
" ETH, not the approved " +
TX_VALUE_ETH,
);
assert(
signed.data === TX_DATA,
"the broadcast transaction carries different call data: " +
signed.data,
);
assert(
signed.chainId === 1n,
"the broadcast transaction is for chain " + signed.chainId,
);
assert(
outcome.result === signed.hash,
"the page received " +
outcome.result +
", not the hash of the broadcast transaction " +
signed.hash,
);
assert(
waitHash.includes(signed.hash),
"the wait screen shows a different hash: " +
JSON.stringify(waitHash),
);
await d.switchToWindow(popup);
await d.closeWindow(env.dappWindow);
},
);
step(
"closing an approval window rejects the request with 4001",
async (env) => {
const d = env.driver;
const before = env.server.broadcast.length;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign-closed", "personal_sign", [
SIGN_HEX,
env.address,
]);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-sign");
// Closed, not rejected: this is the windows.onRemoved path, which can
// only fire if windows.create() handed back a window id for the
// approval to be matched against — call site 4 in the issue, and the
// reason suppressing that write-back turns this step red.
await d.closeWindow(env.dappWindow);
await assertUserRejection(d, "sign-closed", "a closed approval window");
assert(
env.server.broadcast.length === before,
"a closed approval window still put a transaction on the node",
);
},
);
// ------------------------------------------------------------- runner
// Uncaught extension errors that are known, tracked and deliberately
// tolerated, in the same spirit as ALLOWED_ERRORS in tests/e2e/harness.js:
// every entry names the issue that will delete it, and every occurrence is
// still printed, so tolerating one is visible in the log rather than silent.
// This is the only concession in an otherwise zero-tolerance policy.
const ALLOWED_ERRORS = [
{
// The site-connection buttons in src/popup/views/approval.js send
// their decision and call window.close() on the next line. Firefox's
// BaseContext.wrapPromise reports, through Cu.reportError, any
// extension-API promise that settles after its context unloaded —
// whether or not the caller attached a handler, so notify()'s catch
// cannot suppress it.
//
// Pre-existing, and not introduced by the promise shim: the send was
// already unawaited, and this suite is merely the first thing to
// drive that window on Firefox. It is the same teardown ordering as
// the issue below, whose fix — making the outcome independent of when
// the popup closes — removes this entry with it.
pattern: /Promise (?:resolved|rejected) after context unloaded/,
source: /\/src\/popup\/index\.js$/,
issue: "https://git.eeqj.de/sneak/AutistMask/issues/275",
},
];
function allowedFor(e) {
return ALLOWED_ERRORS.find(
(a) => a.pattern.test(e.msg) && a.source.test(e.src),
);
}
function formatError(e) {
return (
e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")"
);
}
async function main() {
// A suite that runs nothing must never report success.
if (steps.length === 0) {
console.log("1..0");
console.log("# FAILED: the Firefox e2e suite registered no steps");
process.exitCode = 1;
return;
}
const extDir = path.resolve(REPO_ROOT, process.argv[2] || "dist/firefox");
if (!fs.existsSync(path.join(extDir, "manifest.json"))) {
console.error(
"e2e-firefox: no unpacked build at " +
extDir +
" — run make build first",
);
process.exitCode = 1;
return;
}
// Loopback survives --network none, so this is the http:// origin the
// dApp steps need and the node they talk to. Started before the browser
// so its url is available to the first step that asks for it.
let server;
try {
server = await startDappServer();
} catch (e) {
console.error(
"e2e-firefox: cannot serve the dApp fixture: " + e.message,
);
process.exitCode = 1;
return;
}
console.log("# dapp fixture: " + server.url + " rpc " + server.rpcUrl);
let driver;
try {
driver = await start();
await driver.newSession();
await driver.installAddon(extDir);
} catch (e) {
// A browser we cannot start is a failure of the suite, not an
// absent suite. Never skip and report success.
console.error("e2e-firefox: cannot run the suite: " + e.message);
if (driver) await driver.quit().catch(() => {});
await server.close();
process.exitCode = 1;
return;
}
const errors = new ConsoleErrors(driver, EXTENSION_ORIGIN);
const env = {
driver,
server,
phrase: null,
address: null,
dappWindow: null,
popupWindow: null,
};
console.log("# extension origin: " + EXTENSION_ORIGIN);
console.log("1.." + steps.length);
let failed = 0;
let n = 0;
try {
// Drain, never reset: anything the add-on logged while installing
// and starting its background page has no earlier step to belong
// to, so it is folded into step 1 below. Services.console.reset()
// here would DELETE it instead, and a background page that throws
// at the top of the file — a dead background page — would then
// produce a fully green run.
let installErrors = [];
let installFailure = null;
try {
installErrors = await errors.take();
} catch (e) {
installFailure =
"could not read the console after install: " + e.message;
}
for (const s of steps) {
n += 1;
let failure = null;
try {
await withTimeout(s.fn(env), s.name);
} catch (e) {
failure = e.message;
}
// Let anything the step provoked reach the console service
// before draining it. Without this a failure logged on the
// way out of the step lands in the next step's drain, which
// still fails the run but blames the wrong step.
await sleep(500);
let found = [];
try {
found = await errors.take();
} catch (e) {
failure = failure || "could not read the console: " + e.message;
}
if (n === 1) {
found = installErrors.concat(found);
installErrors = [];
failure = failure || installFailure;
installFailure = null;
}
// Tolerated errors are set aside, never dropped: each one is
// printed with the issue that keeps it on the list, so the
// concession stays in the run output.
const tolerated = found.filter((e) => allowedFor(e));
found = found.filter((e) => !allowedFor(e));
for (const e of tolerated) {
console.log(
"# tolerated (" +
allowedFor(e).issue +
"): " +
formatError(e),
);
}
// Any uncaught error from an extension source fails the step
// that provoked it, whether or not its assertions passed.
if (!failure && found.length > 0) {
failure =
n === 1
? "uncaught extension errors during add-on install, " +
"background startup or this step"
: "uncaught extension errors during this step";
}
if (failure) {
failed += 1;
console.log("not ok " + n + " - " + s.name);
console.log(" " + failure);
for (const e of found) console.log(" " + formatError(e));
} else {
console.log("ok " + n + " - " + s.name);
}
}
// The tail: errors logged after the last step returned cannot be
// blamed on any one step, but they are still reported and they
// still fail the run.
await sleep(1000);
const trailingAll = await errors.take();
for (const e of trailingAll.filter((x) => allowedFor(x))) {
console.log(
"# tolerated (" + allowedFor(e).issue + "): " + formatError(e),
);
}
const trailing = trailingAll.filter((e) => !allowedFor(e));
console.log(
"# " +
(steps.length - failed) +
"/" +
steps.length +
" steps passed",
);
if (trailing.length > 0) {
console.log(
"# " +
trailing.length +
" extension error(s) recorded after the last step, not " +
"attributable to any single step:",
);
for (const e of trailing) console.log("# " + formatError(e));
}
// A JSON-RPC method nothing answered means the extension asked the
// node something this fixture does not model, and whatever depended
// on the answer took the error branch instead. That is a hole in the
// fixture, not a pass.
if (server.unstubbed.length > 0) {
console.log(
"# FAILED: no fixture for JSON-RPC method(s) " +
[...new Set(server.unstubbed)].join(", "),
);
process.exitCode = 1;
}
if (failed > 0 || trailing.length > 0) {
console.log("# FAILED");
process.exitCode = 1;
}
} finally {
await driver.quit().catch(() => {});
await server.close();
}
}
main().catch((e) => {
console.error("e2e-firefox: " + (e && e.stack ? e.stack : e));
process.exitCode = 1;
});