test: containerized Chrome end-to-end harness that drives the real popup (closes #181)
Some checks failed
check / check (push) Has been cancelled
Some checks failed
check / check (push) Has been cancelled
Runs the real popup in a pinned containerized Chrome and fails on any uncaught page error or console.error. Also fixes the two defects it caught: the missing showView import in addToken.js and the missing addressDotHtml import in transactionDetail.js. closes #150 closes #151
This commit was merged in pull request #185.
This commit is contained in:
289
tests/e2e/harness.js
Normal file
289
tests/e2e/harness.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// End-to-end harness: launches a real Chromium with the unpacked MV3
|
||||
// build loaded, collects every uncaught page error and console.error, and
|
||||
// exposes the popup flows the tests drive.
|
||||
//
|
||||
// This runs inside the pinned Playwright container; see script/test-e2e.
|
||||
// It is deliberately NOT part of make check — REPO_POLICIES.md caps
|
||||
// make test at 20 seconds and a browser suite does not fit.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const { chromium } = require("playwright-core");
|
||||
const { installNetworkStubs } = require("./network");
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
|
||||
|
||||
// Page errors that are known, tracked, and deliberately tolerated. Every
|
||||
// entry must name the issue that will remove it. This list is the one
|
||||
// concession in an otherwise zero-tolerance policy: an uncaught error is
|
||||
// how this harness caught issue #150 in the first place.
|
||||
const ALLOWED_ERRORS = [
|
||||
{
|
||||
// libsodium ships a WASM build and an asm.js fallback. The
|
||||
// extension CSP (script-src 'self', with no wasm-unsafe-eval)
|
||||
// refuses the WASM module on every popup load; libsodium catches
|
||||
// it and falls back to asm.js, so the wallet works. Deciding
|
||||
// which backend actually ships is issue #182, and this entry gets
|
||||
// deleted when that lands.
|
||||
issue: "#182",
|
||||
pattern: /Refused to compile or instantiate WebAssembly module/,
|
||||
},
|
||||
];
|
||||
|
||||
function isAllowed(text) {
|
||||
return ALLOWED_ERRORS.some((a) => a.pattern.test(text));
|
||||
}
|
||||
|
||||
// Collects every uncaught page error, console.error and unstubbed
|
||||
// request, and hands each one to exactly one reporter.
|
||||
//
|
||||
// This deliberately has NO window API. It used to expose mark()/since()
|
||||
// so a test could ask for "the errors since I started", and that shape
|
||||
// produced a green run that proved nothing twice over: first the mark
|
||||
// started after test 1, so everything recorded during launch was
|
||||
// discarded, then the tail after the final test was never read at all. In
|
||||
// both cases a record fell outside somebody's window and vanished, which
|
||||
// is the precise failure this harness exists to prevent.
|
||||
//
|
||||
// So there is no window left to fall outside of. take() is the only
|
||||
// reader and it always takes everything outstanding, so successive takes
|
||||
// partition the entire record stream with no gaps, and the runner turns
|
||||
// every record it reads into a failure.
|
||||
//
|
||||
// Observation ends when the browser context is closed. Nothing records
|
||||
// after that — the route handler and the console listeners are gone with
|
||||
// the context — so there is no post-teardown phase to collect, and this
|
||||
// class deliberately offers no mechanism pretending to cover one.
|
||||
class ErrorCollector {
|
||||
constructor() {
|
||||
this.entries = [];
|
||||
this.taken = 0;
|
||||
}
|
||||
|
||||
record(kind, text) {
|
||||
const line = kind + ": " + String(text).split("\n")[0];
|
||||
if (isAllowed(line)) return;
|
||||
this.entries.push(line);
|
||||
}
|
||||
|
||||
// Everything recorded since the previous take(). Never yields a
|
||||
// record twice and never skips one.
|
||||
take() {
|
||||
const out = this.entries.slice(this.taken);
|
||||
this.taken = this.entries.length;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
function attachErrorListeners(ctx, errors) {
|
||||
const attachPage = (page) => {
|
||||
page.on("pageerror", (err) => {
|
||||
errors.record("pageerror", err.message || String(err));
|
||||
});
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
errors.record("console.error", msg.text());
|
||||
}
|
||||
});
|
||||
};
|
||||
ctx.pages().forEach(attachPage);
|
||||
ctx.on("page", attachPage);
|
||||
// Per-page listeners only: the context-level "weberror" event covers
|
||||
// the same page exceptions and would double-report them. Playwright
|
||||
// exposes no error EVENT for service workers, so an uncaught
|
||||
// exception in the background worker is not visible here — everything
|
||||
// this suite drives lives in the popup page. That is an error-channel
|
||||
// gap only: worker NETWORK traffic is intercepted and reported like
|
||||
// any other, and assertWorkerTrafficIntercepted() below fails the run
|
||||
// if it ever stops being.
|
||||
}
|
||||
|
||||
async function serviceWorker(ctx) {
|
||||
const [existing] = ctx.serviceWorkers();
|
||||
if (existing) return existing;
|
||||
return ctx.waitForEvent("serviceworker", { timeout: 30000 });
|
||||
}
|
||||
|
||||
// How long to wait for the background worker's first outbound request.
|
||||
//
|
||||
// The margin that actually decides whether this check is sound is not
|
||||
// this timeout — it is whether the route handler is installed before the
|
||||
// worker fetches. Measured over several runs: route installation
|
||||
// completes 11-23ms after the context comes up, and the worker's
|
||||
// blocklist fetch arrives 525-883ms after that, so the route wins by
|
||||
// roughly 25-50x. This 30s figure is only slack for a loaded machine on
|
||||
// top of that; losing the race fails the run rather than passing it
|
||||
// quietly, which was verified by forcing a 3s delay before route
|
||||
// installation.
|
||||
const WORKER_TRAFFIC_TIMEOUT_MS = 30000;
|
||||
|
||||
// ctx.route() only sees service-worker requests when Playwright runs with
|
||||
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
|
||||
// sets. Without it the worker's traffic — notably the phishing blocklist
|
||||
// fetch src/background/index.js issues at startup — goes to the real
|
||||
// internet, and nothing says so, because src/shared/phishingDomains.js
|
||||
// swallows fetch failures. A harness whose isolation can lapse in silence
|
||||
// is worthless, so this does not take the flag on trust: the background
|
||||
// worker's own startup fetch has to show up in the route handler, or the
|
||||
// suite refuses to run.
|
||||
//
|
||||
// Deliberately NOT a synthetic probe fetched through worker.evaluate():
|
||||
// evaluating in an extension worker this early kills it (the call fails
|
||||
// with "Target page, context or browser has been closed" and the worker
|
||||
// disappears), which would break the very thing being measured. Observing
|
||||
// traffic the extension already generates costs nothing and cannot
|
||||
// perturb it.
|
||||
async function assertWorkerTrafficIntercepted(stubs) {
|
||||
const seen = await stubs.waitForServiceWorkerTraffic(
|
||||
WORKER_TRAFFIC_TIMEOUT_MS,
|
||||
);
|
||||
if (seen) return seen;
|
||||
|
||||
// State the observation, not a conclusion. This fires for at least
|
||||
// two quite different causes and the harness cannot tell them apart
|
||||
// from here, so guessing one of them in the message sends the reader
|
||||
// the wrong way.
|
||||
throw new Error(
|
||||
"observed no service-worker request in the route handler within " +
|
||||
WORKER_TRAFFIC_TIMEOUT_MS +
|
||||
"ms. Under working interception the background worker's " +
|
||||
"startup blocklist fetch (src/background/index.js) reaches the " +
|
||||
"handler about half a second after the route is installed. " +
|
||||
"Two causes are plausible and this check cannot distinguish " +
|
||||
"them: (1) service-worker interception is not in effect, so " +
|
||||
"that traffic went to the real internet unobserved — the suite " +
|
||||
"must be run through script/test-e2e, which sets " +
|
||||
"PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
|
||||
"Playwright upgrade may have dropped or renamed that flag; " +
|
||||
"(2) no worker request was made in the first place — the route " +
|
||||
"lost the startup race, or the worker no longer fetches at " +
|
||||
"startup, in which case this check needs a new anchor because " +
|
||||
"there is no longer any worker traffic to observe. Either way " +
|
||||
"the fix is a replacement mechanism or an honest downgrade of " +
|
||||
"the isolation claims in tests/e2e/network.js and README.md — " +
|
||||
"not deleting this check",
|
||||
);
|
||||
}
|
||||
|
||||
async function launch(routeOpts) {
|
||||
if (!fs.existsSync(path.join(EXT_PATH, "manifest.json"))) {
|
||||
throw new Error(
|
||||
"no unpacked build at " +
|
||||
EXT_PATH +
|
||||
" — run make build before the e2e suite",
|
||||
);
|
||||
}
|
||||
|
||||
const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-e2e-"));
|
||||
const ctx = await chromium.launchPersistentContext(userDir, {
|
||||
// channel: "chromium" is load-bearing. The default headless mode
|
||||
// uses the headless shell, which silently refuses to load
|
||||
// extensions: there is no error at all, the service worker simply
|
||||
// never appears. This cost real debugging time once already.
|
||||
channel: "chromium",
|
||||
headless: true,
|
||||
args: [
|
||||
"--disable-extensions-except=" + EXT_PATH,
|
||||
"--load-extension=" + EXT_PATH,
|
||||
// The container runs unprivileged; Chrome's sandbox needs
|
||||
// capabilities the harness deliberately does not grant it.
|
||||
"--no-sandbox",
|
||||
// Belt to the interception braces: nothing that slips past
|
||||
// the route handler can resolve a name, so a request that
|
||||
// escapes cannot actually reach the internet. Detection is
|
||||
// still assertWorkerTrafficIntercepted()'s job — this only
|
||||
// bounds the damage while a gap goes unnoticed. Playwright
|
||||
// fulfils routed requests without touching the resolver, and
|
||||
// it drives the browser over a pipe, so neither is affected.
|
||||
"--host-resolver-rules=MAP * ~NOTFOUND",
|
||||
],
|
||||
});
|
||||
|
||||
const cleanup = async () => {
|
||||
await ctx.close().catch(() => {});
|
||||
fs.rmSync(userDir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
try {
|
||||
const errors = new ErrorCollector();
|
||||
attachErrorListeners(ctx, errors);
|
||||
routeOpts.report = (text) => errors.record("network", text);
|
||||
const stubs = await installNetworkStubs(ctx, routeOpts);
|
||||
|
||||
await assertWorkerTrafficIntercepted(stubs);
|
||||
|
||||
// The extension id is derived from the unpacked path, so it
|
||||
// changes and must never be hardcoded. It is the host part of the
|
||||
// service worker URL.
|
||||
const sw = await serviceWorker(ctx);
|
||||
const id = new URL(sw.url()).host;
|
||||
|
||||
return {
|
||||
ctx,
|
||||
errors,
|
||||
extensionId: id,
|
||||
popupUrl: "chrome-extension://" + id + "/src/popup/index.html",
|
||||
close: cleanup,
|
||||
};
|
||||
} catch (e) {
|
||||
// Anything that fails after the browser is up has to tear it down
|
||||
// on the way out: an orphaned context keeps node alive forever,
|
||||
// turning a clean failure into a hung run.
|
||||
await cleanup();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- flows
|
||||
|
||||
const PASSWORD = "e2e-harness-password";
|
||||
|
||||
async function visible(page, selector, timeout = 15000) {
|
||||
await page.waitForSelector(selector, { state: "visible", timeout });
|
||||
}
|
||||
|
||||
async function openPopup(ctx, popupUrl) {
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(popupUrl);
|
||||
return page;
|
||||
}
|
||||
|
||||
// Full wallet creation through the real UI: BIP-39 generation, libsodium
|
||||
// vault encryption and extension storage persistence, for real.
|
||||
async function createWallet(page) {
|
||||
await page.click("#btn-welcome-add");
|
||||
await visible(page, "#view-add-wallet");
|
||||
await page.click("#btn-generate-phrase");
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.getElementById("wallet-mnemonic");
|
||||
return el && el.value.trim().split(/\s+/).length >= 12;
|
||||
});
|
||||
await page.fill("#add-wallet-password", PASSWORD);
|
||||
await page.fill("#add-wallet-password-confirm", PASSWORD);
|
||||
await page.click("#btn-add-wallet-confirm");
|
||||
await visible(page, "#view-main", 60000);
|
||||
}
|
||||
|
||||
// Reach the address detail screen from wherever the popup restored to.
|
||||
// Clicking .address-row does not open it; the [info] button does.
|
||||
async function openAddressDetail(page) {
|
||||
const onAddress = await page.isVisible("#view-address");
|
||||
if (!onAddress) {
|
||||
await visible(page, "#view-main");
|
||||
await page.click("#wallet-list .btn-addr-info");
|
||||
}
|
||||
await visible(page, "#view-address");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createWallet,
|
||||
launch,
|
||||
openAddressDetail,
|
||||
openPopup,
|
||||
visible,
|
||||
};
|
||||
Reference in New Issue
Block a user