test: containerized Chrome end-to-end harness that drives the real popup (closes #181)
Some checks failed
check / check (push) Has been cancelled

`make check` was green while the AddToken screen crashed on every open.
`script/lint` is only `prettier --check`, so a used-but-not-imported
identifier is invisible until a browser evaluates it. This adds a suite
that runs the real popup in a real Chrome and treats any uncaught page
error or console.error as a failure.

- `script/test-e2e` (with `make test-e2e` as a thin shim) builds
  `dist/chrome/` and runs `tests/e2e/run.js` inside the Playwright image,
  pinned by digest. `playwright-core` is pinned to the matching 1.56.0
  through `yarn.lock`; the two must be bumped together because the
  browsers ship inside the image.
- Deliberately outside `script/test` and `script/check`: REPO_POLICIES
  caps `make test` at 20 seconds. Nothing under `tests/e2e/` is named
  `*.test.js`, so jest cannot pick it up either.
- Launches with `channel: "chromium"`; the default headless shell
  silently refuses to load extensions with no error at all. The extension
  id is read from the service worker URL, never hardcoded.
- All http(s) traffic is intercepted at the browser level and served from
  fixtures, so the run is deterministic and offline. Unrecognised
  outbound requests are reported as failures rather than allowed.
- A missing build or an unavailable container fails loudly; a skip that
  looks like a pass is the failure mode this is meant to prevent.
- One allowlisted page error, for the libsodium WASM CSP fallback tracked
  as #182, which is otherwise untouched here.

The suite was demonstrated failing against the unfixed tree with
`pageerror: showView is not defined` and `pageerror: addressDotHtml is
not defined`, so it carries the two one-line import fixes it caught:

closes #150 — `showView` restored to the destructure in
`src/popup/views/addToken.js`, dropped by a22f33d, which made the
AddToken screen unreachable and corrupted the navigation stack.

closes #151 — `addressDotHtml` restored in
`src/popup/views/transactionDetail.js`, dropped by df031fd, which threw
before `showView("transaction")` for every ERC-20 transfer. The shared
`renderAddressHtml` helper is not used here on purpose: it hardcodes the
`/address/` explorer URL, and this row needs the token-specific `/token/`
link.
This commit is contained in:
2026-08-09 14:24:36 +00:00
parent 23aeae4841
commit a6885a0d62
11 changed files with 710 additions and 12 deletions

188
tests/e2e/harness.js Normal file
View File

@@ -0,0 +1,188 @@
// 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));
}
class ErrorCollector {
constructor() {
this.entries = [];
}
record(kind, text) {
const line = kind + ": " + String(text).split("\n")[0];
if (isAllowed(line)) return;
this.entries.push(line);
}
mark() {
return this.entries.length;
}
since(mark) {
return this.entries.slice(mark);
}
}
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 error in
// the background worker is not visible here — everything this suite
// drives lives in the popup page.
}
// 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.
async function extensionId(ctx) {
let [sw] = ctx.serviceWorkers();
if (!sw) {
sw = await ctx.waitForEvent("serviceworker", { timeout: 30000 });
}
return new URL(sw.url()).host;
}
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",
],
});
const errors = new ErrorCollector();
attachErrorListeners(ctx, errors);
routeOpts.report = (text) => errors.record("network", text);
await installNetworkStubs(ctx, routeOpts);
const id = await extensionId(ctx);
const popupUrl = "chrome-extension://" + id + "/src/popup/index.html";
return {
ctx,
errors,
extensionId: id,
popupUrl,
async close() {
await ctx.close();
fs.rmSync(userDir, { recursive: true, force: true });
},
};
}
// ---------------------------------------------------------------- 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 = {
ALLOWED_ERRORS,
EXT_PATH,
REPO_ROOT,
createWallet,
launch,
openAddressDetail,
openPopup,
visible,
};