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
290 lines
12 KiB
JavaScript
290 lines
12 KiB
JavaScript
// 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,
|
|
};
|