manifest/chrome.json now carries a fixed public key, so the extension id and the chrome.storage.local partition holding the wallet stay stable across checkout moves and re-clones instead of being derived from the absolute path. A release entrypoint produces a self-contained versioned artifact per browser, including the files that sit at dist/ root outside both browser directories. One version source of truth, enforced: the build fails naming the culprit when the two manifests and package.json disagree, and BUILD_COMMIT now marks a dirty tree as dirty. Firefox ships an unsigned XPI; the README states that release Firefox and ESR refuse it, that Developer Edition or Unbranded is required, and that Remove is irreversible except from the recovery phrase, which is asserted by test.
580 lines
21 KiB
JavaScript
580 lines
21 KiB
JavaScript
// 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
|
|
|
|
// `profileDir` reuses an existing profile directory in place instead of
|
|
// letting geckodriver make a throwaway one. That is the only way to ask
|
|
// what survives a browser RESTART, which for a Firefox add-on that can
|
|
// only be installed temporarily is the question that decides whether the
|
|
// extension is usable at all: a temporary add-on is unloaded when Firefox
|
|
// exits, so every session begins by adding it again.
|
|
async newSession(profileDir) {
|
|
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,
|
|
}),
|
|
// The container has loopback and nothing else. Firefox's own
|
|
// link-status detection can read that as "offline" and then
|
|
// refuse every request, including the ones to the loopback dApp
|
|
// origin the suite serves; this takes the decision away from it.
|
|
"network.manage-offline-status": false,
|
|
// Force the site-connection prompt down its windows.create()
|
|
// fallback.
|
|
//
|
|
// src/background/index.js prefers the toolbar-anchored popup for
|
|
// that one approval and opens a real window only when
|
|
// openPopup() refuses. A panel is not a top-level browsing
|
|
// context, so WebDriver cannot see it, list it or click in it —
|
|
// the same blind spot the Chrome harness documents. Leaving this
|
|
// at its default would make which path runs depend on whether a
|
|
// headless Firefox counts as having had a user gesture, which is
|
|
// not a thing to leave to chance in a suite that has to be able
|
|
// to fail. The window path is shipped code and the same approval
|
|
// id, so what is driven is real; what is NOT covered either way
|
|
// is the panel presentation itself.
|
|
"extensions.openPopupWithoutUserGesture.enabled": false,
|
|
};
|
|
|
|
const 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",
|
|
];
|
|
if (profileDir) args.push("-profile", profileDir);
|
|
|
|
const value = await this.send("POST", "/session", {
|
|
capabilities: {
|
|
alwaysMatch: {
|
|
browserName: "firefox",
|
|
"moz:firefoxOptions": {
|
|
binary: FIREFOX_BIN,
|
|
args,
|
|
prefs,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
this.sessionId = value.sessionId;
|
|
await this.session("POST", "/timeouts", { script: SCRIPT_TIMEOUT_MS });
|
|
return value;
|
|
}
|
|
|
|
// Installs the MV2 build, either from an unpacked directory or from an
|
|
// XPI file. temporary:true bypasses signature checks — which is the only
|
|
// way an UNSIGNED xpi installs at all, and the reason README.md says
|
|
// release Firefox will refuse the artifact this repo produces — and the
|
|
// add-on dies with the profile.
|
|
//
|
|
// Returns the add-on id Firefox assigned, which is
|
|
// browser_specific_settings.gecko.id from the manifest and is what
|
|
// uninstallAddon() takes.
|
|
async installAddon(pathToAddon) {
|
|
return this.session("POST", "/moz/addon/install", {
|
|
path: pathToAddon,
|
|
temporary: true,
|
|
});
|
|
}
|
|
|
|
// Removes an installed add-on, the way clicking Remove in about:addons
|
|
// does. tests/e2e/firefox/reinstall.js uses it to ask the one question
|
|
// that decides whether this extension can be used at all on Firefox: does
|
|
// the vault survive being removed and added again.
|
|
async uninstallAddon(id) {
|
|
return this.session("POST", "/moz/addon/uninstall", { id });
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
|
|
// The asynchronous form: the script is handed a resolve callback as its
|
|
// last argument and the call settles when that is invoked. Everything
|
|
// interesting about an extension page is promise-shaped — storage reads,
|
|
// the provider's own request() — and /execute/sync cannot wait for any
|
|
// of it.
|
|
async executeAsync(script, args = []) {
|
|
await this.setContext("content");
|
|
return this.session("POST", "/execute/async", { 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;
|
|
// Assigned on every path through the loop body before it is read, so
|
|
// there is no initializer to give it.
|
|
let last;
|
|
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],
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------ windows
|
|
//
|
|
// The approval prompts this suite drives are separate top-level windows
|
|
// the extension opens itself, so every one of them is a window handle
|
|
// here and the suite has to move between them explicitly.
|
|
|
|
async windowHandles() {
|
|
return this.session("GET", "/window/handles");
|
|
}
|
|
|
|
async currentWindow() {
|
|
return this.session("GET", "/window");
|
|
}
|
|
|
|
async switchToWindow(handle) {
|
|
await this.setContext("content");
|
|
await this.session("POST", "/window", { handle });
|
|
}
|
|
|
|
async newWindow(type = "window") {
|
|
await this.setContext("content");
|
|
const value = await this.session("POST", "/window/new", { type });
|
|
return value.handle;
|
|
}
|
|
|
|
// Closes the current window and leaves the session on `fallback`, because
|
|
// a session whose current window is gone fails every subsequent command
|
|
// with "no such window" rather than with anything diagnosable.
|
|
async closeWindow(fallback) {
|
|
await this.setContext("content");
|
|
await this.session("DELETE", "/window");
|
|
if (fallback) await this.switchToWindow(fallback);
|
|
}
|
|
|
|
async url() {
|
|
return this.session("GET", "/url");
|
|
}
|
|
|
|
// The handle of the first window whose URL matches, or null. Restores the
|
|
// window that was current before the search either way: a probe that
|
|
// silently relocates the session is a trap for the step after it.
|
|
async findWindow(predicate) {
|
|
const origin = await this.currentWindow();
|
|
try {
|
|
for (const handle of await this.windowHandles()) {
|
|
await this.switchToWindow(handle);
|
|
if (predicate(await this.url())) return handle;
|
|
}
|
|
return null;
|
|
} finally {
|
|
// Tolerated: the window the search started from may have been the
|
|
// one that just closed, and a throw in here would replace the
|
|
// real result with "no such window".
|
|
await this.switchToWindow(origin).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------- 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 scripts ARE now exercised: tests/e2e/firefox/dapp.js serves a page
|
|
// from loopback, which survives --network none, and the suite drives the
|
|
// EIP-1193 round trips through the content script injected into it. What is
|
|
// still unproven is the CAPTURE, not the execution — no probe has forced a
|
|
// throw from inside a content script and watched it fail the run, so an
|
|
// uncaught content-script error arriving by this route remains an
|
|
// expectation rather than a demonstrated fact. Do not claim otherwise.
|
|
//
|
|
// Warnings are excluded so the semantics match Playwright's pageerror:
|
|
// uncaught errors only.
|
|
//
|
|
// The read and the clear are ONE chrome script on purpose. Splitting them
|
|
// into two round trips leaves a blind window between them in which an
|
|
// error is logged into a buffer that is about to be discarded, and is
|
|
// destroyed unread rather than deferred to the next drain. That was not
|
|
// theoretical: with a separate reset() call, a probe of 100 sequenced
|
|
// throws at 20ms spacing lost one of them outright.
|
|
const DRAIN_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,
|
|
});
|
|
}
|
|
Services.console.reset();
|
|
return out;
|
|
`;
|
|
|
|
class ConsoleErrors {
|
|
constructor(driver, originPrefix) {
|
|
this.driver = driver;
|
|
this.originPrefix = originPrefix;
|
|
}
|
|
|
|
// Everything logged since the last take, read and cleared atomically
|
|
// in a single chrome round trip. 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. An error that arrives mid-drain is not lost — it makes this
|
|
// batch or the next one — but the console service ring buffer holds
|
|
// only 250 messages, so more than that between two takes evicts the
|
|
// oldest unread. A clean run peaks at 4.
|
|
async take() {
|
|
const found = await this.driver.executeChrome(DRAIN_ERRORS_SCRIPT, [
|
|
this.originPrefix,
|
|
]);
|
|
return found || [];
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- 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,
|
|
};
|