test: containerized Firefox end-to-end harness (closes #184)
All checks were successful
check / check (push) Successful in 28s
All checks were successful
check / check (push) Successful in 28s
Drives the real popup in a real Firefox with dist/firefox/ installed as an unpacked MV2 temporary add-on, via geckodriver. Covers popup load, wallet creation through the UI, and the Add Token screen. Outside make check, like the Chrome suite. Zero npm dependencies: tests/e2e/firefox/driver.js is a WebDriver client over global fetch and child_process against geckodriver's HTTP API. The Dockerfile pins the node base image, the Firefox 153.0.3 tarball and geckodriver 0.36.0 by digest. Errors are read from the privileged nsIConsoleService in Marionette's chrome context, filtered to non-warning entries whose sourceName is the extension origin. BiDi log.entryAdded delivers nothing at all for extension pages, so a Playwright-BiDi or Puppeteer-BiDi harness would see nothing and report success; the code says so where someone would be tempted to simplify it. Errors logged during add-on install and background startup are drained and folded into step 1, never discarded: a throw at the top of src/background/index.js kills the background page and fails the run. Content-script capture is left as unverified, because --network none leaves no http:// page for a content script to be injected into. No driver layer is shared with the Chrome suite and the three UI steps are written twice deliberately: the two backends have no common substrate, and three steps do not pay for a shim. Two limits are documented rather than papered over. Error capture is poll-based, so an error is attributed to a step and not to a moment within it, and an error logged more than ~1s after the last step is missed entirely because the browser is torn down. Nothing is stubbed; the container runs with --network none instead, which proves no request escaped, cannot report which were attempted, and runs only the failure branches of network-dependent code.
This commit is contained in:
51
tests/e2e/firefox/Dockerfile
Normal file
51
tests/e2e/firefox/Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
||||
# Firefox end-to-end image: stock Firefox plus geckodriver on a node base,
|
||||
# built by script/test-e2e-firefox. The repo is bind-mounted at /work; the
|
||||
# harness itself has no dependencies, so nothing is installed for it.
|
||||
#
|
||||
# All three external artifacts are pinned by digest. The Firefox version in
|
||||
# particular must not float: -remote-allow-system-access is mandatory on 153
|
||||
# and was not on 142, so the flag the harness passes is version-coupled.
|
||||
|
||||
# node:22-bookworm-slim, 2026-08-12
|
||||
FROM node@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Firefox's shared-library dependencies on a slim base, plus the two tools
|
||||
# needed to fetch and unpack the pinned tarballs.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libasound2 \
|
||||
libdbus-glib-1-2 \
|
||||
libgtk-3-0 \
|
||||
libx11-xcb1 \
|
||||
libxt6 \
|
||||
libxtst6 \
|
||||
xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Firefox 153.0.3, linux-x86_64, en-US
|
||||
ARG FIREFOX_URL=https://ftp.mozilla.org/pub/firefox/releases/153.0.3/linux-x86_64/en-US/firefox-153.0.3.tar.xz
|
||||
ARG FIREFOX_SHA256=22b312280900bfb174b685ece32c7b3c6d72e7f8e53d6d30f21ac41a8dc500a2
|
||||
RUN curl -fsSL -o /tmp/firefox.tar.xz "$FIREFOX_URL" \
|
||||
&& echo "$FIREFOX_SHA256 /tmp/firefox.tar.xz" | sha256sum -c - \
|
||||
&& tar -xJf /tmp/firefox.tar.xz -C /opt \
|
||||
&& rm /tmp/firefox.tar.xz \
|
||||
&& /opt/firefox/firefox --version
|
||||
|
||||
# geckodriver v0.36.0, linux64
|
||||
ARG GECKODRIVER_URL=https://github.com/mozilla/geckodriver/releases/download/v0.36.0/geckodriver-v0.36.0-linux64.tar.gz
|
||||
ARG GECKODRIVER_SHA256=0bde38707eb0a686a20c6bd50f4adcc7d60d4f73c60eb83ee9e0db8f65823e04
|
||||
RUN curl -fsSL -o /tmp/geckodriver.tar.gz "$GECKODRIVER_URL" \
|
||||
&& echo "$GECKODRIVER_SHA256 /tmp/geckodriver.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/geckodriver.tar.gz -C /usr/local/bin \
|
||||
&& rm /tmp/geckodriver.tar.gz \
|
||||
&& geckodriver --version
|
||||
|
||||
ENV FIREFOX_BIN=/opt/firefox/firefox
|
||||
ENV GECKODRIVER=/usr/local/bin/geckodriver
|
||||
|
||||
WORKDIR /work
|
||||
CMD ["node", "tests/e2e/firefox/run.js", "dist/firefox"]
|
||||
461
tests/e2e/firefox/driver.js
Normal file
461
tests/e2e/firefox/driver.js
Normal file
@@ -0,0 +1,461 @@
|
||||
// A minimal WebDriver client for geckodriver, plus the privileged console
|
||||
// reader the error assertions are built on. No npm dependencies: global
|
||||
// fetch and child_process against geckodriver's HTTP API is less code than
|
||||
// a driver library and keeps the harness at zero packages.
|
||||
//
|
||||
// Run through script/test-e2e-firefox, which builds dist/firefox/ and the
|
||||
// pinned container around this. FIREFOX_BIN and GECKODRIVER locate the two
|
||||
// binaries; the image sets both.
|
||||
|
||||
"use strict";
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
const net = require("net");
|
||||
|
||||
const FIREFOX_BIN = process.env.FIREFOX_BIN || "firefox";
|
||||
const GECKODRIVER = process.env.GECKODRIVER || "geckodriver";
|
||||
|
||||
// The extension id declared in manifest/firefox.json, and the uuid the
|
||||
// popup is served from. Firefox normally assigns that uuid randomly per
|
||||
// profile, which would make the popup URL undiscoverable without querying
|
||||
// privileged state; setting extensions.webextensions.uuids before launch
|
||||
// pins it instead. This only works because the manifest declares a fixed
|
||||
// browser_specific_settings.gecko.id — without one the mapping has no key.
|
||||
const EXTENSION_ID = "autistmask@sneak.berlin";
|
||||
const EXTENSION_UUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
const EXTENSION_ORIGIN = "moz-extension://" + EXTENSION_UUID;
|
||||
|
||||
// The W3C web element identifier. Getting the last character wrong yields
|
||||
// an element reference of "undefined" and a bewildering "element with the
|
||||
// reference undefined is not known" from geckodriver, so findElement()
|
||||
// below checks for the key rather than indexing blindly.
|
||||
const WEB_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
||||
|
||||
const SCRIPT_TIMEOUT_MS = 120000;
|
||||
const DEFAULT_WAIT_MS = 20000;
|
||||
const POLL_INTERVAL_MS = 100;
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// An ephemeral port picked by the kernel, then handed to geckodriver.
|
||||
// There is a race between closing this listener and geckodriver binding,
|
||||
// but this host runs many sessions at once and a fixed 4444 is a
|
||||
// guaranteed collision rather than a possible one.
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.on("error", reject);
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const { port } = srv.address();
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class WebDriverError extends Error {
|
||||
constructor(command, body) {
|
||||
const v = (body && body.value) || {};
|
||||
super(
|
||||
command +
|
||||
" failed: " +
|
||||
(v.error || "unknown error") +
|
||||
": " +
|
||||
(v.message || JSON.stringify(body)),
|
||||
);
|
||||
this.name = "WebDriverError";
|
||||
this.error = v.error;
|
||||
}
|
||||
}
|
||||
|
||||
class Driver {
|
||||
constructor(proc, base) {
|
||||
this.proc = proc;
|
||||
this.base = base;
|
||||
this.sessionId = null;
|
||||
this.context = "content";
|
||||
}
|
||||
|
||||
async send(method, path, body) {
|
||||
const url = this.base + path;
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (_) {
|
||||
throw new Error(
|
||||
method + " " + path + ": non-JSON response: " + text,
|
||||
);
|
||||
}
|
||||
if (!res.ok) throw new WebDriverError(method + " " + path, parsed);
|
||||
return parsed.value;
|
||||
}
|
||||
|
||||
session(method, path, body) {
|
||||
return this.send(method, "/session/" + this.sessionId + path, body);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ setup
|
||||
|
||||
async newSession() {
|
||||
const prefs = {
|
||||
// See EXTENSION_UUID above. The pref is a string pref whose
|
||||
// value is itself JSON.
|
||||
"extensions.webextensions.uuids": JSON.stringify({
|
||||
[EXTENSION_ID]: EXTENSION_UUID,
|
||||
}),
|
||||
};
|
||||
|
||||
const value = await this.send("POST", "/session", {
|
||||
capabilities: {
|
||||
alwaysMatch: {
|
||||
browserName: "firefox",
|
||||
"moz:firefoxOptions": {
|
||||
binary: FIREFOX_BIN,
|
||||
args: [
|
||||
"-headless",
|
||||
// Mandatory on Firefox 153: without it,
|
||||
// navigating to moz-extension:// and running
|
||||
// chrome-context script both fail with
|
||||
// "unsupported operation".
|
||||
//
|
||||
// It grants the driver FULL CHROME PRIVILEGES
|
||||
// over this browser. Acceptable only because
|
||||
// the browser is a throwaway in a CI
|
||||
// container; never point a session with this
|
||||
// flag at anything you care about.
|
||||
"-remote-allow-system-access",
|
||||
],
|
||||
prefs,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
this.sessionId = value.sessionId;
|
||||
await this.session("POST", "/timeouts", { script: SCRIPT_TIMEOUT_MS });
|
||||
return value;
|
||||
}
|
||||
|
||||
// Installs the unpacked MV2 build straight from a directory.
|
||||
// temporary:true bypasses signature checks, so no XPI and no signing
|
||||
// are involved, and the add-on dies with the profile.
|
||||
async installAddon(dir) {
|
||||
return this.session("POST", "/moz/addon/install", {
|
||||
path: dir,
|
||||
temporary: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Classic navigation on purpose. BiDi's browsingContext.navigate
|
||||
// refuses moz-extension:// URLs outright.
|
||||
async navigate(url) {
|
||||
await this.session("POST", "/url", { url });
|
||||
}
|
||||
|
||||
async quit() {
|
||||
if (this.sessionId) {
|
||||
await this.session("DELETE", "").catch(() => {});
|
||||
this.sessionId = null;
|
||||
}
|
||||
this.proc.kill("SIGTERM");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- scripts
|
||||
|
||||
async setContext(context) {
|
||||
if (this.context === context) return;
|
||||
await this.session("POST", "/moz/context", { context });
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async execute(script, args = []) {
|
||||
await this.setContext("content");
|
||||
return this.session("POST", "/execute/sync", { script, args });
|
||||
}
|
||||
|
||||
// Runs in the privileged chrome scope, where Services and Ci exist.
|
||||
async executeChrome(script, args = []) {
|
||||
await this.setContext("chrome");
|
||||
try {
|
||||
return await this.session("POST", "/execute/sync", {
|
||||
script,
|
||||
args,
|
||||
});
|
||||
} finally {
|
||||
await this.setContext("content");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- page waits
|
||||
|
||||
// Polls a content-context expression until it returns truthy. Every
|
||||
// wait in the suite goes through here so a timeout always says which
|
||||
// condition it was waiting on rather than "timed out".
|
||||
async waitFor(what, script, args = [], timeout = DEFAULT_WAIT_MS) {
|
||||
const deadline = Date.now() + timeout;
|
||||
let last = null;
|
||||
for (;;) {
|
||||
try {
|
||||
const v = await this.execute(script, args);
|
||||
if (v) return v;
|
||||
last = null;
|
||||
} catch (e) {
|
||||
// A navigation or view swap in flight makes execute
|
||||
// throw; that is a not-yet, not a failure, until the
|
||||
// deadline says otherwise.
|
||||
last = e.message;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
"timed out after " +
|
||||
timeout +
|
||||
"ms waiting for " +
|
||||
what +
|
||||
(last ? " (last error: " + last + ")" : ""),
|
||||
);
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// Shown means shown: in the popup a view is switched by toggling a
|
||||
// "hidden" class, and an element that is present but collapsed is not
|
||||
// the thing a test means by visible.
|
||||
async waitVisible(selector, timeout = DEFAULT_WAIT_MS) {
|
||||
return this.waitFor(
|
||||
"selector " + selector + " to be visible",
|
||||
`const el = document.querySelector(arguments[0]);
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;`,
|
||||
[selector],
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
|
||||
async isVisible(selector) {
|
||||
return this.execute(
|
||||
`const el = document.querySelector(arguments[0]);
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;`,
|
||||
[selector],
|
||||
);
|
||||
}
|
||||
|
||||
async count(selector) {
|
||||
return this.execute(
|
||||
"return document.querySelectorAll(arguments[0]).length;",
|
||||
[selector],
|
||||
);
|
||||
}
|
||||
|
||||
async text(selector) {
|
||||
return this.execute(
|
||||
`const el = document.querySelector(arguments[0]);
|
||||
return el ? el.textContent : null;`,
|
||||
[selector],
|
||||
);
|
||||
}
|
||||
|
||||
async title() {
|
||||
return this.session("GET", "/title");
|
||||
}
|
||||
|
||||
// The id of the view element currently on top, which is what a
|
||||
// failing step needs to report: "the screen did not change" is only
|
||||
// useful if it says which screen it stayed on.
|
||||
async currentView() {
|
||||
return this.execute(
|
||||
`const views = document.querySelectorAll('[id^="view-"]');
|
||||
for (const v of views) {
|
||||
const r = v.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) return v.id;
|
||||
}
|
||||
return null;`,
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- interactions
|
||||
|
||||
async findElement(selector) {
|
||||
const value = await this.session("POST", "/element", {
|
||||
using: "css selector",
|
||||
value: selector,
|
||||
});
|
||||
const ref = value && value[WEB_ELEMENT_KEY];
|
||||
if (typeof ref !== "string") {
|
||||
throw new Error(
|
||||
"no " +
|
||||
WEB_ELEMENT_KEY +
|
||||
" in the element response for " +
|
||||
selector +
|
||||
": " +
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
// Real WebDriver clicks and real key events rather than in-page
|
||||
// .click() and value assignment: the popup's handlers are wired to
|
||||
// events, and synthesising them from inside the page would test the
|
||||
// harness's idea of the UI instead of the UI.
|
||||
async click(selector) {
|
||||
await this.waitVisible(selector);
|
||||
const id = await this.findElement(selector);
|
||||
await this.session("POST", "/element/" + id + "/click", {});
|
||||
}
|
||||
|
||||
async fill(selector, value) {
|
||||
await this.waitVisible(selector);
|
||||
const id = await this.findElement(selector);
|
||||
await this.session("POST", "/element/" + id + "/clear", {});
|
||||
await this.session("POST", "/element/" + id + "/value", {
|
||||
text: String(value),
|
||||
});
|
||||
}
|
||||
|
||||
async value(selector) {
|
||||
return this.execute(
|
||||
`const el = document.querySelector(arguments[0]);
|
||||
return el ? el.value : null;`,
|
||||
[selector],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- error capture
|
||||
|
||||
// Uncaught errors from extension code, read out of the privileged console
|
||||
// service.
|
||||
//
|
||||
// This is not the obvious mechanism, and the obvious one does not work:
|
||||
// WebDriver BiDi's log.entryAdded delivers NOTHING for extension pages.
|
||||
// Verified on Firefox 142 and 153 against a same-session control — a plain
|
||||
// http:// page yields uncaught errors with stack traces, the
|
||||
// moz-extension:// popup yields zero events, because the remote agent
|
||||
// excludes extension browsing contexts from BiDi observation. A harness
|
||||
// built on Playwright-BiDi or Puppeteer-BiDi therefore sees nothing and
|
||||
// reports success. Do not "simplify" this back to BiDi.
|
||||
//
|
||||
// nsIConsoleService is not per-page: it also carries errors from the
|
||||
// background page, which BiDi would not have covered even if it worked.
|
||||
// Background-page capture is verified by probe — a throw at the top of
|
||||
// src/background/index.js, which kills the background page outright, fails
|
||||
// the run. Content-script errors should arrive by the same route, but that
|
||||
// is UNVERIFIED here and must not be claimed: the container runs with
|
||||
// --network none, so there is no http:// page for a content script to be
|
||||
// injected into and this suite never exercises one.
|
||||
//
|
||||
// Warnings are excluded so the semantics match Playwright's pageerror:
|
||||
// uncaught errors only.
|
||||
const READ_ERRORS_SCRIPT = `
|
||||
const origin = arguments[0];
|
||||
const out = [];
|
||||
for (const raw of Services.console.getMessageArray() || []) {
|
||||
let e;
|
||||
try {
|
||||
e = raw.QueryInterface(Ci.nsIScriptError);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (e.flags & Ci.nsIScriptError.warningFlag) continue;
|
||||
const src = e.sourceName || "";
|
||||
if (!src.startsWith(origin)) continue;
|
||||
out.push({
|
||||
msg: e.errorMessage,
|
||||
src: src,
|
||||
line: e.lineNumber,
|
||||
cat: e.category,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
`;
|
||||
|
||||
class ConsoleErrors {
|
||||
constructor(driver, originPrefix) {
|
||||
this.driver = driver;
|
||||
this.originPrefix = originPrefix;
|
||||
}
|
||||
|
||||
// Everything logged since the last reset, then clear. Poll-based, so
|
||||
// an error is attributed to the step that was running when it was
|
||||
// drained, not to the moment inside that step at which it happened —
|
||||
// see the limitation note in run.js.
|
||||
async take() {
|
||||
const found = await this.driver.executeChrome(READ_ERRORS_SCRIPT, [
|
||||
this.originPrefix,
|
||||
]);
|
||||
await this.reset();
|
||||
return found || [];
|
||||
}
|
||||
|
||||
// Discards the console outright. Anything logged and not yet taken is
|
||||
// destroyed unread, so this is only ever correct straight after a
|
||||
// take(); to move past a window of errors, take() them and report
|
||||
// them somewhere.
|
||||
async reset() {
|
||||
await this.driver.executeChrome("Services.console.reset(); return 0;");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- startup
|
||||
|
||||
async function waitForDriverReady(base, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
try {
|
||||
const res = await fetch(base + "/status");
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
if (body && body.value && body.value.ready !== false) return;
|
||||
}
|
||||
} catch (_) {
|
||||
// not listening yet
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
"geckodriver did not become ready within " + timeoutMs + "ms",
|
||||
);
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const port = await freePort();
|
||||
const proc = spawn(
|
||||
GECKODRIVER,
|
||||
["--port", String(port), "--host", "127.0.0.1"],
|
||||
{ stdio: ["ignore", "inherit", "inherit"] },
|
||||
);
|
||||
proc.on("error", (e) => {
|
||||
console.error("geckodriver failed to spawn: " + e.message);
|
||||
});
|
||||
|
||||
const base = "http://127.0.0.1:" + port;
|
||||
try {
|
||||
await waitForDriverReady(base, 30000);
|
||||
} catch (e) {
|
||||
proc.kill("SIGKILL");
|
||||
throw e;
|
||||
}
|
||||
return new Driver(proc, base);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ConsoleErrors,
|
||||
Driver,
|
||||
EXTENSION_ID,
|
||||
EXTENSION_ORIGIN,
|
||||
EXTENSION_UUID,
|
||||
start,
|
||||
sleep,
|
||||
};
|
||||
282
tests/e2e/firefox/run.js
Normal file
282
tests/e2e/firefox/run.js
Normal file
@@ -0,0 +1,282 @@
|
||||
// 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 one second after the last
|
||||
// step returns — but only that far: an error logged more than that ~1s
|
||||
// tail after the last step is never observed at all, because the browser
|
||||
// is torn down first. 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;
|
||||
});
|
||||
Reference in New Issue
Block a user