refactor: one shared extension-API module, and drive the dApp flows on Firefox (closes #153)
All checks were successful
check / check (push) Successful in 27s
e2e / e2e-chrome (push) Successful in 45s
e2e / e2e-firefox (push) Successful in 23s

This commit was merged in pull request #281.
This commit is contained in:
2026-08-17 09:06:13 +02:00
parent ab1c1846a7
commit 4b7a678a9b
17 changed files with 1639 additions and 286 deletions

View File

@@ -110,6 +110,26 @@ class Driver {
"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 value = await this.send("POST", "/session", {
@@ -179,6 +199,16 @@ class Driver {
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");
@@ -329,6 +359,63 @@ class Driver {
[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
@@ -349,10 +436,15 @@ class Driver {
// 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.
// 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.