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/run.js Normal file
View File

@@ -0,0 +1,188 @@
// End-to-end suite entrypoint. Run via script/test-e2e (which builds
// dist/chrome/ and starts the pinned container); running it directly
// requires a Chromium that playwright-core can find.
//
// A plain runner rather than jest on purpose: jest's default testMatch
// would pull these files into script/test, and browser tests do not fit
// inside the 20-second cap REPO_POLICIES.md puts on make test. Nothing
// here is named *.test.js for the same reason.
"use strict";
const {
createWallet,
launch,
openAddressDetail,
openPopup,
visible,
} = require("./harness");
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
const TEST_TIMEOUT_MS = 120000;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, message) {
if (!cond) throw new Error(message);
}
function withTimeout(promise, name) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(
() =>
reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")),
TEST_TIMEOUT_MS,
);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
// ----------------------------------------------------------------- tests
test("popup loads and reaches the welcome view", async (env) => {
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-welcome");
const title = await env.page.title();
assert(title === "AutistMask", "unexpected popup title: " + title);
});
test("wallet creation through the UI reaches the main view", async (env) => {
await createWallet(env.page);
const addrCount = await env.page
.locator("#wallet-list .btn-addr-info")
.count();
assert(addrCount > 0, "no addresses rendered in the wallet list");
});
test("add token screen opens from address detail (#150)", async (env) => {
await openAddressDetail(env.page);
await env.page.click("#btn-add-token");
await visible(env.page, "#view-add-token");
const quickPicks = await env.page
.locator("#common-token-list .common-token")
.count();
assert(quickPicks > 0, "no common-token quick-pick buttons rendered");
});
test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
// Serve the stubbed token transfer from here on, then reload so the
// address detail screen refetches its transaction list.
env.routeOpts.seedTokenTransfer = true;
await env.page.reload();
await openAddressDetail(env.page);
await visible(env.page, "#tx-list .tx-row");
const rowText = await env.page
.locator("#tx-list .tx-row")
.first()
.innerText();
assert(
rowText.includes(STUB_TOKEN.symbol),
"token transfer row missing symbol " +
STUB_TOKEN.symbol +
", got: " +
JSON.stringify(rowText),
);
await env.page.locator("#tx-list .tx-row").first().click();
await visible(env.page, "#view-transaction");
const hash = await env.page.locator("#tx-detail-hash").innerText();
assert(
hash.includes(STUB_TX_HASH),
"transaction detail shows the wrong hash: " + hash,
);
// The token contract row is the field that crashes when
// addressDotHtml is not imported: it renders only for transfers with
// a contractAddress, which is every ERC-20 transfer.
await visible(env.page, "#tx-detail-token-contract-section");
const contract = env.page.locator("#tx-detail-token-contract");
const contractText = await contract.innerText();
assert(
contractText.toLowerCase().includes(STUB_TOKEN.address),
"token contract row missing the contract address, got: " +
JSON.stringify(contractText),
);
const dots = await contract.locator('span[style*="border-radius"]').count();
assert(dots > 0, "token contract row rendered without its colour dot");
});
// ---------------------------------------------------------------- runner
async function main() {
const routeOpts = { seedTokenTransfer: false };
let session;
try {
session = await launch(routeOpts);
} catch (e) {
// Never skip and report success: a browser we cannot start is a
// failure of the suite, not an absent one.
console.error("e2e: could not start the browser: " + e.message);
process.exitCode = 1;
return;
}
console.log("# extension id: " + session.extensionId);
console.log("1.." + tests.length);
const env = {
ctx: session.ctx,
popupUrl: session.popupUrl,
routeOpts,
page: null,
};
let failed = 0;
let n = 0;
for (const t of tests) {
n += 1;
const mark = session.errors.mark();
let failure = null;
try {
await withTimeout(t.fn(env), t.name);
} catch (e) {
failure = e.message;
}
// Any uncaught page error or console.error fails the test that
// provoked it, whether or not its assertions passed. This is the
// mechanism that caught #150.
const newErrors = session.errors.since(mark);
if (!failure && newErrors.length > 0) {
failure = "uncaught browser errors during this test";
}
if (failure) {
failed += 1;
console.log("not ok " + n + " - " + t.name);
console.log(" " + failure);
for (const line of newErrors) {
console.log(" " + line);
}
} else {
console.log("ok " + n + " - " + t.name);
}
}
await session.close();
console.log(
"# " + (tests.length - failed) + "/" + tests.length + " passed",
);
if (failed > 0) {
console.log("# FAILED");
process.exitCode = 1;
}
}
main().catch((e) => {
console.error("e2e: " + (e && e.stack ? e.stack : e));
process.exitCode = 1;
});