289 lines
10 KiB
JavaScript
289 lines
10 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. 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 === "MUTANT", "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;
|
|
});
|