Copies .dockerignore, .gitignore, .prettierignore, check.yml and REPO_POLICIES.md from sneak/prompts at dd4027b. The repo's own entries (dist/, release/, yarn files) are kept after the canonical content. The Dockerfile gets separate lint and test phases. Its last stage depends on both, checks the git describe version and runs make build. script/lint, test, check, cibuild and docker are the canonical models. check-censored moves into the lint phase and test-verify-build into the test phase. fmt and fmt-check fall back to the nvm-installed node. The e2e image builds are uncached. Comments that cited the old test caps now say 60 seconds, and comments that named what runs a script now name the Dockerfile phase or stage. Model: opus-5-5
414 lines
17 KiB
JavaScript
414 lines
17 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 60 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, WORKER_PROBE_URL } = 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.
|
|
//
|
|
// Empty, and worth keeping that way. Its only entry was the WASM
|
|
// CompileError libsodium provoked on every popup load, deleted with #182
|
|
// when both manifests started allowing WASM; the run that used to need it
|
|
// is now the run that proves the fix.
|
|
const ALLOWED_ERRORS = [];
|
|
|
|
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.
|
|
//
|
|
// One narrow exception exists, and it is not a mute: expect(). A test that
|
|
// drives a failure path on purpose — a refused gas estimate, say — provokes
|
|
// the console.error the code is supposed to emit, and that error is the
|
|
// behaviour under test rather than an escape. Declaring it consumes exactly
|
|
// one matching record and no more, and an expectation nothing matched fails
|
|
// its test just as an unexpected error does. So it cannot be used to
|
|
// silence anything: it can only assert that a specific error happened.
|
|
class ErrorCollector {
|
|
constructor() {
|
|
this.entries = [];
|
|
this.taken = 0;
|
|
this.expectations = [];
|
|
}
|
|
|
|
// Declare a console.error this test is about to cause deliberately.
|
|
// `label` names it in the failure message if it never arrives.
|
|
expect(label, pattern) {
|
|
this.expectations.push({ label, pattern, matched: false });
|
|
}
|
|
|
|
// Declared expectations that nothing matched, clearing the list so each
|
|
// test starts with none outstanding.
|
|
unmatchedExpectations() {
|
|
const out = this.expectations
|
|
.filter((e) => !e.matched)
|
|
.map((e) => e.label);
|
|
this.expectations = [];
|
|
return out;
|
|
}
|
|
|
|
record(kind, text) {
|
|
const line = kind + ": " + String(text).split("\n")[0];
|
|
if (isAllowed(line)) return;
|
|
const expected = this.expectations.find(
|
|
(e) => !e.matched && e.pattern.test(line),
|
|
);
|
|
if (expected) {
|
|
expected.matched = true;
|
|
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.
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// The most recently seen background worker, waiting for one if none has
|
|
// appeared yet. Most recent rather than first: Chrome stops an idle MV3
|
|
// worker and starts a fresh one on the next event, and a handle to a
|
|
// stopped worker cannot be evaluated in.
|
|
async function serviceWorker(ctx) {
|
|
const workers = ctx.serviceWorkers();
|
|
const latest = workers[workers.length - 1];
|
|
if (latest) return latest;
|
|
return ctx.waitForEvent("serviceworker", { timeout: 30000 });
|
|
}
|
|
|
|
// How long to wait for the probe request the worker is asked to make.
|
|
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 every fetch the background worker makes goes to the
|
|
// real internet and nothing says so. A harness whose isolation can lapse
|
|
// in silence is worthless, so this does not take the flag on trust: a
|
|
// request the worker itself issues has to show up in the route handler,
|
|
// or the suite refuses to run.
|
|
//
|
|
// The anchor is a probe the harness asks the worker for, not traffic the
|
|
// extension generates on its own. It used to be the phishing blocklist
|
|
// fetch src/background/index.js issued at startup; that fetch is gone —
|
|
// the blocklist is vendored at build time and the extension contacts
|
|
// nobody when it starts — so there is no longer any startup traffic to
|
|
// observe and the check generates its own.
|
|
//
|
|
// Evaluating in the worker straight after launch does not work, and that
|
|
// is not a stale observation: it was tried again here and failed with
|
|
// "Target page, context or browser has been closed" on the first run.
|
|
// Chrome stops the freshly registered worker as soon as it has nothing to
|
|
// do, and the extension no longer gives it anything to do — which is the
|
|
// same change that removed the old anchor. So the probe wakes the worker
|
|
// before it evaluates in it, by sending it a message from an extension
|
|
// page and waiting for the reply: delivering a message is what starts a
|
|
// stopped worker, and a worker that has just answered one is alive.
|
|
// The evaluated fetch is not awaited, so nothing in the worker is held
|
|
// open by the probe either.
|
|
async function wakeWorker(ctx) {
|
|
const sw = await serviceWorker(ctx);
|
|
const extensionId = new URL(sw.url()).host;
|
|
const page = await ctx.newPage();
|
|
try {
|
|
await page.goto(
|
|
"chrome-extension://" + extensionId + "/src/popup/index.html",
|
|
);
|
|
// eth_chainId is answered from local state: it wakes the worker
|
|
// and changes nothing.
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
chrome.runtime.sendMessage(
|
|
{
|
|
type: "AUTISTMASK_RPC",
|
|
method: "eth_chainId",
|
|
params: [],
|
|
},
|
|
() => resolve(null),
|
|
);
|
|
}),
|
|
);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
}
|
|
|
|
async function probeFromWorker(ctx, url) {
|
|
let lastError = null;
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
try {
|
|
await wakeWorker(ctx);
|
|
const sw = await serviceWorker(ctx);
|
|
await sw.evaluate((u) => {
|
|
// Deliberately not awaited and never rejected: what is
|
|
// being observed is that the request reaches the route
|
|
// handler, and an unhandled rejection in the worker would
|
|
// be collected as a suite error if it did not.
|
|
fetch(u).catch(() => {});
|
|
}, url);
|
|
return;
|
|
} catch (e) {
|
|
lastError = e;
|
|
await sleep(500);
|
|
}
|
|
}
|
|
throw new Error(
|
|
"could not ask the background worker to fetch " +
|
|
url +
|
|
", so service-worker interception was never tested. Last " +
|
|
"error: " +
|
|
(lastError && lastError.message),
|
|
);
|
|
}
|
|
|
|
async function assertWorkerTrafficIntercepted(ctx, stubs) {
|
|
await probeFromWorker(ctx, WORKER_PROBE_URL);
|
|
|
|
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, although the background worker was asked to fetch " +
|
|
WORKER_PROBE_URL +
|
|
". Two causes are plausible and this check cannot distinguish " +
|
|
"them: (1) service-worker interception is not in effect, so " +
|
|
"that request 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) the probe never ran, because the worker was torn down " +
|
|
"between being handed over and being evaluated in. 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(ctx, 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 });
|
|
}
|
|
|
|
// An empty WebAssembly module: magic number and version header, no
|
|
// sections. Compiling it in the popup asks the one question that decides
|
|
// libsodium's backend — may this realm compile WebAssembly — of the real
|
|
// page under the real shipped manifest, which is the only place the
|
|
// answer can be observed. Kept independent of src/shared/vault.js on
|
|
// purpose: a bundle asked to grade itself proves less than an outside
|
|
// observation of the same realm.
|
|
const EMPTY_WASM_MODULE = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
|
|
|
|
async function pageCompilesWasm(page) {
|
|
return page.evaluate(async (bytes) => {
|
|
try {
|
|
await WebAssembly.compile(new Uint8Array(bytes));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, EMPTY_WASM_MODULE);
|
|
}
|
|
|
|
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.
|
|
//
|
|
// Returns the recovery phrase it generated. Tests that assert on a secret
|
|
// need the real value — checking for "some 12 words" would pass against the
|
|
// wrong wallet's phrase, and checking for nothing at all would pass against
|
|
// a screen that shows the phrase it was supposed to hide.
|
|
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;
|
|
});
|
|
const phrase = (await page.inputValue("#wallet-mnemonic")).trim();
|
|
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);
|
|
return phrase;
|
|
}
|
|
|
|
// Reach the address detail screen of the FIRST address of the first wallet,
|
|
// from wherever the popup restored to. Clicking .address-row does not open
|
|
// it; the [info] button does.
|
|
//
|
|
// .first() rather than a bare selector because the suite adds a second
|
|
// wallet partway through, and every later test would otherwise die in
|
|
// Playwright's strict mode rather than on an assertion.
|
|
async function openAddressDetail(page) {
|
|
const onAddress = await page.isVisible("#view-address");
|
|
if (!onAddress) {
|
|
await visible(page, "#view-main");
|
|
await page.locator("#wallet-list .btn-addr-info").first().click();
|
|
}
|
|
await visible(page, "#view-address");
|
|
}
|
|
|
|
module.exports = {
|
|
PASSWORD,
|
|
createWallet,
|
|
launch,
|
|
openAddressDetail,
|
|
openPopup,
|
|
pageCompilesWasm,
|
|
visible,
|
|
};
|