// 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, which on Firefox is exactly the seam // https://git.eeqj.de/sneak/AutistMask/issues/153 is about: every one of // these paths used to hand a Chrome-style callback to the promise-only // browser.* namespace and simply never complete. // // 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 the // uuid src/content/index.js reads out of extension storage — call site 1 // in the issue — and it 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 stored 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, where the id used to // be assigned from a callback the browser.* namespace never invoked. 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; });