// 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. Three duplicated steps do not pay for a shim; revisit if // this suite grows to where they do. // // 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 { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver"); 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"); }); // ------------------------------------------------------------- runner 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; } 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(() => {}); process.exitCode = 1; return; } const errors = new ConsoleErrors(driver, EXTENSION_ORIGIN); const env = { driver, phrase: 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; } // 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 trailing = await errors.take(); 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)); } if (failed > 0 || trailing.length > 0) { console.log("# FAILED"); process.exitCode = 1; } } finally { await driver.quit().catch(() => {}); } } main().catch((e) => { console.error("e2e-firefox: " + (e && e.stack ? e.stack : e)); process.exitCode = 1; });