Some checks failed
check / check (push) Failing after 2m16s
script/lint ran `prettier --check .`, byte for byte what script/fmt-check
runs, so make check checked formatting twice and did no static analysis on
a cryptocurrency wallet. Two used-but-not-imported crashes shipped past it.
ESLint is pinned in package.json with @eslint/js recommended as the base and
a flat config in eslint.config.js. no-undef and no-unused-vars are restated
error-level so a future recommended-set change cannot downgrade them.
Globals are declared per tree rather than globally, because a too-wide set
hides the next unimported identifier: browser for the popup and content
scripts, service worker for src/background/ and src/shared/, browser for the
one documented POPUP ONLY module in src/shared/, jest for tests/, node for
build.js, and both for the e2e harnesses, which carry the callbacks they
ship into the page inline.
Two rules new to the recommended set are off, and both would have cost
something to satisfy. no-useless-assignment flags the `password = null` and
`decryptedSecret = null` wipes in approval.js and confirmTx.js: those
assignments are dead by construction, which is the point of them, and the
rule's fix is to delete the wipe. preserve-caught-error would change what
the wallet's error paths throw, which is a decision of its own.
Every remaining violation is fixed: 41 unused bindings and 53 undefined
identifiers. Unused catch bindings became `catch {`, which the repo already
used; the shared init(ctx) view signature keeps its parameter as _ctx in the
three views that do not read it. src/shared/uniswap.js keeps its unused
V2_SWAP_EXACT_OUT decoder behind a scoped disable, because deleting it would
widen the gap it represents rather than close it (#283).
Linting is containerized. script/lint builds the Dockerfile's new lint stage
so the ESLint deciding whether this repo is green is the pinned one and not
whatever the host has; AUTISTMASK_LINT_NATIVE, set only in that image, is
what makes make check inside the CI build lint in place instead of recursing
into docker. The check stage takes a COPY --from=lint dependency so a lint
failure fails the whole build early rather than racing it.
No --fix anywhere in the lint path: make check remains non-mutating.
The README claim that a used-but-not-imported identifier is invisible to
make check, and the same claim in script/test-e2e, are no longer true and
are corrected.
350 lines
14 KiB
JavaScript
350 lines
14 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.
|
|
//
|
|
// 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.
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
// 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,
|
|
};
|