Files
AutistMask/tests/e2e/run.js
clawbot dbd83fee02
All checks were successful
check / check (push) Successful in 30s
test: drive the EIP-1193 dApp approval round trips in the browser (closes #183)
The seam between the content script, the inpage provider, the background worker
and the approval popup had no coverage at all. The unit suite covers each side
in isolation, so a wallet that signed the wrong payload, handed back a signature
from the wrong key, hung on a rejected prompt, or put the user's password back
on the extension messaging boundary would have passed everything.

Ten end-to-end tests now drive it. The route handler serves a local test page on
a reserved-TLD origin; window.ethereum arrives there from the shipped MAIN-world
content script, not from anything the fixture installs, and the page's own
EIP-6963 announcement is required to be that provider by object identity. The
page then speaks eth_requestAccounts, personal_sign, eth_signTypedData_v4 and
eth_sendTransaction through the real prompts, approved and rejected.

The assertions are the point:

  - every signature is recovered in the runner with verifyMessage,
    verifyTypedData and Transaction.from(), and compared against the active
    address read out of extension storage. The background verifies too; nothing
    here leans on its verdict.
  - the transaction is checked against the raw signed bytes captured at
    eth_sendRawTransaction, which the RPC stub now records and answers with the
    transaction's real hash. Signer, recipient, value, call data and chain id
    are all compared there, and the hash the page received is required to be the
    hash of those bytes.
  - a rejected prompt must reach the page as a rejection rather than hang or
    resolve, and must carry EIP-1193 code 4001 across the boundary. The code is
    asserted on the wire because that is where it survives: the inpage provider
    rebuilds the rejection as a bare Error, so the calling page catches a message
    and no code. Reported, not asserted either way.
  - the password must appear in no message the approval window sends to the
    background, observed directly by wrapping chrome.runtime.sendMessage before
    Approve is clicked, with the response message that would carry it required
    to be present so the check cannot pass on an empty record.

Every one of those was run against a deliberately broken variant and seen to
fail: a corrupted recovered signer, a payload carrying the password again, a
provider that resolves instead of rejecting, and approval screens displaying the
wrong message, value and call data.

Two honest limits. The RPC is stubbed throughout, so this does not discharge a
real dApp with real funds against a real network. And the site-connection prompt
goes through chrome.action.openPopup(), whose browser-action popup headless
Chromium will not expose as a page, so that one prompt is driven at the URL the
extension puts on the action instead — same page, same approval id, but a real
toolbar click is not observable from a headless harness. Both are stated in
README.md rather than presented as covered.

Test-only: nothing under src/ changes.
2026-08-12 11:18:24 +00:00

2357 lines
87 KiB
JavaScript

// 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 {
Transaction,
formatEther,
getAddress,
getBytes,
hexlify,
parseEther,
toQuantity,
toUtf8Bytes,
verifyMessage,
verifyTypedData,
} = require("ethers");
const {
PASSWORD,
createWallet,
launch,
openAddressDetail,
openPopup,
pageCompilesWasm,
visible,
} = require("./harness");
const {
DAPP_ORIGIN,
DAPP_URL,
FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI,
STUB_COUNTERPARTY,
STUB_TOKEN,
STUB_TX_HASH,
} = require("./network");
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
const TEST_TIMEOUT_MS = 120000;
// How long to keep collecting after the final test returns; see the
// trailing drain in main().
const TRAILING_WATCH_MS = 1500;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, message) {
if (!cond) throw new Error(message);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
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);
});
// The empirical half of #182. The manifest change is only a claim about
// what the CSP permits; this is the observation. Two things have to hold
// together, and the run covers both: the popup realm compiles WASM (here),
// and no WASM refusal or abort is recorded anywhere in the run — the
// harness allowlist that used to excuse exactly that error is now empty,
// so a recurrence fails whichever test it lands in rather than being
// tolerated. Since libsodium's WASM module is embedded in the bundle and
// needs no fetch, a realm that compiles WASM is a realm where libsodium
// takes the WASM path, and the next test drives a real vault encryption
// through it.
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
const ok = await pageCompilesWasm(env.page);
assert(
ok,
"the popup refused to compile WebAssembly. The shipped manifest CSP " +
"has lost 'wasm-unsafe-eval', so libsodium is back on its wasm2js " +
"fallback and every password derivation costs roughly 20x what it " +
"should — see the backend note in src/shared/vault.js",
);
});
test("wallet creation through the UI reaches the main view", async (env) => {
env.phrase = await createWallet(env.page);
assert(
env.phrase.split(/\s+/).length >= 12,
"wallet creation did not yield a recovery phrase",
);
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");
});
// -------------------------------------------- recovery phrase (#161)
// The gear toggles, so pressing it while Settings is already up leaves it.
async function openSettings(page) {
if (!(await page.isVisible("#view-settings"))) {
await page.click("#btn-settings");
}
await visible(page, "#view-settings");
}
// Everything the recovery phrase screen is holding, read straight out of
// the DOM whether or not that screen is the one on top. Reading it while it
// is hidden is the point: "cleared on leave" means the node is empty, not
// merely off-screen.
async function phraseScreenState(page) {
return page.evaluate(() => ({
value: document.getElementById("show-phrase-value").textContent,
error: document.getElementById("show-phrase-flash").textContent,
html: document.getElementById("view-show-phrase").innerHTML,
resultHidden: document
.getElementById("show-phrase-result")
.classList.contains("hidden"),
viewHidden: document
.getElementById("view-show-phrase")
.classList.contains("hidden"),
}));
}
async function openPhraseScreen(page) {
await openSettings(page);
await page.click("#settings-wallet-list .btn-show-phrase");
await visible(page, "#view-show-phrase");
}
async function revealPhrase(page) {
await page.fill("#show-phrase-password", PASSWORD);
await page.click("#btn-show-phrase-reveal");
await visible(page, "#show-phrase-result", 60000);
}
function assertWiped(st, phrase, where) {
assert(st.value === "", "phrase still in the DOM " + where);
assert(st.resultHidden, "result section still shown " + where);
assert(
!st.html.includes(phrase),
"the recovery phrase is still somewhere in the screen markup " + where,
);
}
test("only an HD wallet is offered the recovery phrase action (#161)", async (env) => {
await openSettings(env.page);
const offered = await env.page
.locator("#settings-wallet-list .btn-show-phrase")
.count();
const wallets = await env.page
.locator("#settings-wallet-list .btn-delete-wallet")
.count();
assert(wallets === 1, "expected exactly one wallet row, got " + wallets);
assert(
offered === 1,
"the HD wallet was not offered the recovery phrase action",
);
});
// The other half of the gate, against the real UI: a wallet holding a bare
// private key has no phrase to show, so no row of it may offer the action.
// The key is generated here rather than committed — the repo holds no
// private keys, test ones included.
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
const { Wallet } = require("ethers");
await openSettings(env.page);
await env.page.click("#btn-main-add-wallet");
await visible(env.page, "#view-add-wallet");
await env.page.click("#tab-privkey");
await env.page.fill(
"#import-private-key",
Wallet.createRandom().privateKey,
);
await env.page.fill("#add-wallet-password", PASSWORD);
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
await env.page.click("#btn-add-wallet-confirm");
await visible(env.page, "#view-main", 60000);
await openSettings(env.page);
const wallets = await env.page
.locator("#settings-wallet-list .btn-delete-wallet")
.count();
const offered = await env.page
.locator("#settings-wallet-list .btn-show-phrase")
.count();
assert(wallets === 2, "expected two wallet rows, got " + wallets);
assert(
offered === 1,
"the key wallet was offered the recovery phrase action",
);
});
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
await openPhraseScreen(env.page);
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "before any password was entered");
const passwordShown = await env.page.isVisible(
"#show-phrase-password-section",
);
assert(passwordShown, "the password prompt is not shown");
});
test("a wrong password reveals nothing (#161)", async (env) => {
await env.page.fill("#show-phrase-password", "not-the-password");
await env.page.click("#btn-show-phrase-reveal");
await env.page.waitForFunction(
() =>
document.getElementById("show-phrase-flash").textContent.length > 0,
null,
{ timeout: 60000 },
);
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "after a wrong password");
assert(
/^[A-Z].*\.$/.test(st.error.trim()),
"the wrong-password error is not a full sentence: " +
JSON.stringify(st.error),
);
});
test("the correct password reveals the full phrase, and nothing logs it (#161)", async (env) => {
const console_ = [];
const listener = (msg) => console_.push(msg.text());
env.page.on("console", listener);
try {
await revealPhrase(env.page);
const st = await phraseScreenState(env.page);
assert(
st.value === env.phrase,
"the displayed phrase is not the wallet's phrase, verbatim",
);
const promptShown = await env.page.isVisible(
"#show-phrase-password-section",
);
assert(!promptShown, "the password prompt is still shown after unlock");
// Full Identifiers Policy: shown whole, and copyable.
const title = await env.page.getAttribute(
"#show-phrase-value",
"title",
);
assert(title === "Click to copy", "the phrase is not click-to-copy");
const leaked = console_.filter((line) => line.includes(env.phrase));
assert(
leaked.length === 0,
"the recovery phrase reached the console: " +
JSON.stringify(leaked),
);
} finally {
env.page.off("console", listener);
}
});
test('"Back" wipes the revealed phrase (#161)', async (env) => {
await env.page.click("#btn-show-phrase-back");
await visible(env.page, "#view-settings");
const st = await phraseScreenState(env.page);
assert(st.viewHidden, "the recovery phrase screen is still on top");
assertWiped(st, env.phrase, "after Back");
});
// The settings gear leaves the screen without touching its Back button. A
// clear wired only to Back would pass the test above and leak here.
test("leaving by the settings gear wipes it too (#161)", async (env) => {
await openPhraseScreen(env.page);
await revealPhrase(env.page);
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
const st = await phraseScreenState(env.page);
assertWiped(st, env.phrase, "after leaving via the settings gear");
});
// The same leave, but taken while the decrypt is still running. Both
// clicks are dispatched inside one page task on purpose: "Reveal" runs its
// handler up to the await, the gear then runs the leave — and the wipe with
// it — to completion, and the decrypt's continuation resumes afterwards.
// Without a liveness check that continuation writes the phrase into the
// hidden screen after the wipe, and nothing is left to wipe it again.
//
// A human cannot produce this interleaving by hand once libsodium's wasm is
// warm, because crypto_pwhash is synchronous and the only suspension point
// is a microtask; the window a user can actually hit is a still-pending
// sodium.ready on the first vault use of a page load. Forcing it here is
// the only way to test the guard deterministically.
test("leaving while the decrypt is in flight reveals nothing (#161)", async (env) => {
await openPhraseScreen(env.page);
await env.page.fill("#show-phrase-password", PASSWORD);
await env.page.evaluate(() => {
document.getElementById("btn-show-phrase-reveal").click();
document.getElementById("btn-settings").click();
});
await visible(env.page, "#view-settings");
// The Reveal button is disabled for exactly the duration of the
// decrypt and re-enabled in the same continuation that would have
// written the phrase, so waiting for it to come back is a precise
// "the decrypt has settled and its handler has finished" signal
// rather than a guess at a duration.
await env.page.waitForFunction(
() => !document.getElementById("btn-show-phrase-reveal").disabled,
null,
{ timeout: 60000 },
);
await sleep(2000);
const st = await phraseScreenState(env.page);
// Printed on every run, pass or fail: "the phrase is not there" is
// worth more as a measurement than as a silent assertion, and the
// same line read from a build without the guard is what this test
// exists to prevent.
console.log(
"# probe: len=" +
st.value.length +
" equalsPhrase=" +
(st.value === env.phrase) +
" resultHidden=" +
st.resultHidden +
" viewHidden=" +
st.viewHidden,
);
assert(st.viewHidden, "the recovery phrase screen is still on top");
assertWiped(st, env.phrase, "after leaving mid-decrypt");
});
// Closing and reopening the page rather than reloading it: that is what
// the toolbar popup actually does, and the persisted currentView is
// "show-phrase" at the moment it happens, which is precisely the state
// RESTORABLE_VIEWS has to refuse.
test("reopening the popup never lands on the phrase screen (#161)", async (env) => {
await openPhraseScreen(env.page);
await revealPhrase(env.page);
await env.page.close();
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-main");
const st = await phraseScreenState(env.page);
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
assertWiped(st, env.phrase, "after reopening the popup");
});
// -------------------------------------------- address removal (#162)
// Number of address rows across every wallet in the list, counted in the DOM
// whether or not Home is the screen on top.
function addressRowCount(page) {
return page.locator("#wallet-list .btn-addr-info").count();
}
function waitForAddressRows(page, n) {
return page.waitForFunction(
(want) =>
document.querySelectorAll("#wallet-list .btn-addr-info").length ===
want,
n,
{ timeout: 60000 },
);
}
// The suite arrives here with two wallets, an HD one and a key one, holding
// one address each.
test("only a wallet that can spare an address offers to remove one (#162)", async (env) => {
await visible(env.page, "#view-main");
const rows = await addressRowCount(env.page);
assert(rows === 2, "expected two address rows, got " + rows);
const offered = await env.page
.locator("#wallet-list .btn-remove-address")
.count();
assert(
offered === 0,
"a wallet holding its last address offered to remove it",
);
await env.page.click("#wallet-list .btn-add-address");
await waitForAddressRows(env.page, 3);
// Only the HD wallet's two rows; the key wallet still holds one address.
const nowOffered = await env.page
.locator("#wallet-list .btn-remove-address")
.count();
assert(
nowOffered === 2,
"expected the HD wallet's two rows to offer removal, got " + nowOffered,
);
});
// The gate itself: the control opens a confirmation, and leaving that
// confirmation by "Back" removes nothing.
test("leaving the removal confirmation removes nothing (#162)", async (env) => {
await env.page.locator("#wallet-list .btn-remove-address").nth(1).click();
await visible(env.page, "#view-delete-address-confirm");
const label = await env.page.locator("#delete-address-label").innerText();
assert(
label === "Address 2",
"the confirmation names the wrong address: " + JSON.stringify(label),
);
// The route back is written by the view, not by index.html, so an empty
// paragraph here means the user is confirming with no idea what it
// takes to undo. This wallet is an HD one, so it is told about its
// recovery phrase.
const recovery = await env.page
.locator("#delete-address-recovery")
.innerText();
assert(
recovery.includes("delete the whole wallet in Settings") &&
recovery.includes("recovery phrase"),
"the confirmation does not state the route back: " +
JSON.stringify(recovery),
);
// "Back" re-renders Home, so a count taken after it is a real
// measurement of the wallet rather than a stale screen.
await env.page.click("#btn-delete-address-back");
await visible(env.page, "#view-main");
const rows = await addressRowCount(env.page);
assert(rows === 3, "the address was removed without a confirmation");
});
test("confirming removes the address and returns Home (#162)", async (env) => {
await env.page.locator("#wallet-list .btn-remove-address").nth(1).click();
await visible(env.page, "#view-delete-address-confirm");
await env.page.click("#btn-delete-address-confirm");
await visible(env.page, "#view-main");
await waitForAddressRows(env.page, 2);
const offered = await env.page
.locator("#wallet-list .btn-remove-address")
.count();
assert(
offered === 0,
"the HD wallet still offers to remove its last address",
);
});
// ------------------------------------------------ dust threshold (#233)
// The popup size README documents the UI as designed for. Pages in this
// context otherwise get Playwright's 1280x720 default, at which the flash
// line has room for any plausible message and never wraps — measuring
// there would pass for every string and prove nothing.
const POPUP_VIEWPORT = { width: 360, height: 600 };
// Everything below the flash line that must not move when it fills, plus
// the height of the line itself. Runs in the page.
//
// Positions are in document coordinates, not viewport coordinates:
// tabbing out of the field to fire "change" scrolls the popup, and a
// getBoundingClientRect().top read across that scroll reports a thousand
// pixels of movement that is the scroll, not a layout shift.
function measureFlashLine() {
const top = (id) =>
document.getElementById(id).getBoundingClientRect().top +
window.scrollY;
return {
text: document.getElementById("flash-msg").textContent,
flashHeight: document
.getElementById("flash-msg")
.getBoundingClientRect().height,
settingsTop: top("view-settings"),
fieldTop: top("settings-dust-threshold"),
};
}
// Polling one evaluate() rather than waitForFunction() plus a second
// round trip to measure: showFlash() clears the line again after 2s, and
// measuring in a separate call can land after that and read an empty
// line — which would pass however long the message is. Here the text
// check and the geometry come from the same page task, so what is
// measured is always the filled line. Missing the 2s window entirely
// throws; it cannot go green.
async function waitForFilledFlashLine(page) {
const deadline = Date.now() + 15000;
for (;;) {
const m = await page.evaluate(measureFlashLine);
if (m.text.length > 0) return m;
if (Date.now() > deadline) {
throw new Error("the flash line never filled");
}
await sleep(25);
}
}
// README, No Layout Shift: the rejection message goes into #flash-msg,
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs. Reserving
// the space is not enough on its own — a message too long for one line
// wraps and pushes everything below it down anyway, which is what the
// first version of this change shipped: 75 characters, 32px, the settings
// view and the threshold field 12px lower than with an empty line.
//
// So this measures rather than inspects markup. It is the only assertion
// in the repo that can see the wording grow: the unit suite runs on the
// node environment with no layout engine, where every height is zero (see
// the note in tests/dustThreshold.test.js). Lengthen
// DUST_THRESHOLD_MESSAGE past one line and this test goes red.
test("a rejected dust threshold shifts no layout (#233)", async (env) => {
const page = await openPopup(env.ctx, env.popupUrl);
try {
await page.setViewportSize(POPUP_VIEWPORT);
await openSettings(page);
const before = await page.evaluate(measureFlashLine);
assert(
before.text === "",
"the flash line was not empty at the baseline measurement: " +
JSON.stringify(before.text),
);
// "change" fires on blur, not on typing, so fill() alone is not
// enough — it only dispatches "input".
await page.fill("#settings-dust-threshold", "1.5");
await page.locator("#settings-dust-threshold").press("Tab");
const after = await waitForFilledFlashLine(page);
// Printed pass or fail: the numbers are the evidence, and a
// silent assertion would leave the reader taking this on trust.
console.log(
"# dust threshold flash: " +
after.text.length +
" chars, line height " +
before.flashHeight +
" -> " +
after.flashHeight +
", view-settings top " +
before.settingsTop +
" -> " +
after.settingsTop +
", field top " +
before.fieldTop +
" -> " +
after.fieldTop,
);
assert(
after.text === DUST_THRESHOLD_MESSAGE,
"the field flashed something other than DUST_THRESHOLD_MESSAGE: " +
JSON.stringify(after.text),
);
assert(
after.flashHeight === before.flashHeight,
"the message does not fit the reserved line: " +
before.flashHeight +
"px empty vs " +
after.flashHeight +
"px with the message. Shorten DUST_THRESHOLD_MESSAGE",
);
assert(
after.settingsTop === before.settingsTop,
"the settings view moved " +
(after.settingsTop - before.settingsTop) +
"px when the message appeared",
);
assert(
after.fieldTop === before.fieldTop,
"the dust threshold field moved " +
(after.fieldTop - before.fieldTop) +
"px when the message appeared",
);
} finally {
await page.close();
}
});
// --------------------------------------------- confirmation screen (#238)
//
// The screen that decides what gets signed. The arithmetic underneath it
// lives in src/shared/txValidation.js and is unit tested there; what these
// tests cover is the wiring — which number reaches the gate, when the gate
// re-runs, what the fee block renders, and whether Send is enabled.
//
// The load-bearing one is "gates on the fee RESERVE": the confirmation
// screen quotes the ESTIMATE and gates on the RESERVE, and issue #154 was
// the gate reading the quoted number. Every other assertion here would
// survive that mutation, so the funded and gap sends are deliberately sized
// on opposite sides of the reserve while sitting on the same side of the
// estimate.
// The balance the funded fixture serves, and the amounts sent against it.
const FUNDED_ETH_WEI = 10n ** 18n;
const FUNDED_ETH_TEXT = "1.0";
const COMFORTABLE_AMOUNT = "0.1";
const OVER_BALANCE_AMOUNT = "2.0";
// A send the balance covers to the wei once the ESTIMATE is added, and does
// not cover once the RESERVE is. Sending this is allowed by a gate reading
// the estimate and refused by a gate reading the reserve, which is the whole
// discrimination these tests exist to make.
const GAP_AMOUNT = formatEther(FUNDED_ETH_WEI - FEE_ESTIMATE_WEI);
// The ERC-20 side. The ETH balance is set to exactly the estimate for the
// fee test: it covers the expected cost to the wei and falls short of the
// reserve, so the same swap flips this assertion too — through a different
// balance and a different message than the ETH path uses.
const TOKEN_BALANCE_TEXT = "1.5";
const TOKEN_AMOUNT = "0.25";
const OVER_TOKEN_AMOUNT = "9.0";
const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI;
function toHexWei(wei) {
return "0x" + wei.toString(16);
}
// A fee in wei as the confirmation screen writes it. Deliberately a second
// implementation of formatFeeEth() from src/popup/views/confirmTx.js rather
// than an import of it: that module pulls in the whole popup and cannot be
// required outside a browser, and asserting against an independent rendering
// is stronger than asserting a function equals itself.
function feeEth(wei) {
const parts = formatEther(wei).split(".");
const dec =
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
return parts[0] + "." + dec + " ETH";
}
// What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, and how tall the whole view is.
async function confirmState(page) {
return page.evaluate(() => {
const el = (id) => document.getElementById(id);
// Both mechanisms matter. The two fee messages are dropped with
// display:none for the transaction type they cannot apply to, and
// shown or hidden with visibility for the one they can.
const shown = (id) => {
const cs = getComputedStyle(el(id));
return cs.display !== "none" && cs.visibility === "visible";
};
return {
height: el("view-confirm-tx").getBoundingClientRect().height,
type: el("confirm-type").textContent.trim(),
balance: el("confirm-balance").textContent.trim(),
fee: el("confirm-fee-amount").textContent.trim(),
reserve: el("confirm-fee-reserve").textContent.trim(),
reserveShown: shown("confirm-fee-reserve"),
errors: shown("confirm-errors")
? el("confirm-errors").textContent.trim()
: "",
amountFeeError: shown("confirm-amount-fee-error"),
gasError: shown("confirm-gas-error"),
feeUnknownError: shown("confirm-fee-unknown-error"),
sendDisabled: el("btn-confirm-send").disabled,
};
});
}
async function waitForEstimate(page) {
await page.waitForFunction(
() =>
document.getElementById("confirm-fee-amount").textContent.trim() !==
"Estimating...",
null,
{ timeout: 60000 },
);
}
async function backToAddress(page) {
if (await page.isVisible("#view-confirm-tx")) {
await page.click("#btn-confirm-back");
await visible(page, "#view-send");
}
if (await page.isVisible("#view-send")) {
await page.click("#btn-send-back");
}
await openAddressDetail(page);
}
// Drive the popup to the confirmation screen for one send.
//
// It waits for the send screen to be showing `balance` before filling
// anything in. That figure is the exact number the spend gate compares
// against, so waiting for it — rather than for a refresh to have probably
// landed — is what keeps every assertion below deterministic after a
// fixture change.
async function goToConfirm(page, { token, balance, amount }) {
await backToAddress(page);
await page.click("#btn-send");
await visible(page, "#view-send");
await page.selectOption("#send-token", token);
await page.waitForFunction(
(want) =>
document.getElementById("send-balance").textContent.trim() === want,
"Current balance: " + balance,
{ timeout: 60000 },
);
await page.fill("#send-to", STUB_COUNTERPARTY);
await page.fill("#send-amount", amount);
await page.click("#btn-send-review");
await visible(page, "#view-confirm-tx");
}
// A balance as the main view renders it: balanceLinesForAddress() writes
// every quantity with four decimal places.
function quantity(wei) {
return parseFloat(formatEther(wei)).toFixed(4);
}
// Wait on the main view until a changed balance fixture has been picked up.
//
// Deliberately not a reload: the popup re-refreshes on a 10-second timer by
// itself, and reloading aborts whatever fetch the home screen has open at
// that instant, which the extension reports through log.errorf and the
// harness — correctly — fails the run on.
//
// It also deliberately settles on MAIN rather than on the address screen.
// The address screen builds the send screen's token dropdown once, from the
// balances it holds at that moment, and nothing rebuilds it when a later
// refresh arrives, so entering it early leaves a dropdown with no token in
// it and the ERC-20 path unreachable.
async function settleOnMain(env, { ethWei, expectToken }) {
await backToAddress(env.page);
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await env.page.waitForFunction(
(want) =>
document.getElementById("wallet-list").textContent.includes(want),
quantity(ethWei),
{ timeout: 60000 },
);
if (expectToken) {
await visible(
env.page,
'#wallet-list [data-token="' + STUB_TOKEN.address + '"]',
60000,
);
}
}
test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true;
await settleOnMain(env, {
ethWei: FUNDED_ETH_WEI,
expectToken: true,
});
env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
const st = await confirmState(env.page);
env.ethPendingHeight = st.height;
console.log("# confirm-tx ETH view height: " + st.height + "px");
assert(
st.type === "Native ETH transfer",
"unexpected transaction type: " + JSON.stringify(st.type),
);
assert(
st.fee === "Estimating...",
"the fee line is not showing the pending placeholder: " +
JSON.stringify(st.fee),
);
assert(!st.reserveShown, "the reserve line is shown before any estimate");
assert(
st.sendDisabled,
"Send is enabled while the fee estimate is still in flight",
);
assert(
!st.feeUnknownError,
"the estimate-failed message is shown for an estimate that is merely pending",
);
assert(
!st.amountFeeError && !st.gasError && st.errors === "",
"a balance message is shown before the fee is known",
);
});
test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238)", async (env) => {
env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page);
const st = await confirmState(env.page);
assert(
st.balance === FUNDED_ETH_TEXT + " ETH",
"the confirmation screen shows the wrong balance: " +
JSON.stringify(st.balance),
);
assert(
st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
"the fee line does not quote the estimate: " + JSON.stringify(st.fee),
);
assert(st.reserveShown, "the reserve line is not shown once the fee lands");
assert(
st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved",
"the reserve line does not quote the reserve: " +
JSON.stringify(st.reserve),
);
assert(
!st.sendDisabled,
"Send is disabled for a comfortably funded transfer",
);
assert(
st.errors === "" &&
!st.amountFeeError &&
!st.gasError &&
!st.feeUnknownError,
"a balance message is shown for a comfortably funded transfer",
);
assert(
st.height === env.ethPendingHeight,
"the view changed height when the estimate landed: " +
env.ethPendingHeight +
"px -> " +
st.height +
"px",
);
});
// The one that closes the hole. Everything else here survives a gate that
// reads the displayed estimate instead of the reserve; this does not.
test("ConfirmTx gates on the fee RESERVE, not the displayed estimate (#238)", async (env) => {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: GAP_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
// Printed on every run, pass or fail: the two fee numbers and the gate's
// decision side by side is the measurement this test is really making.
console.log(
"# gate probe: balance=" +
FUNDED_ETH_TEXT +
" ETH amount=" +
GAP_AMOUNT +
" estimate=" +
feeEth(FEE_ESTIMATE_WEI) +
" reserve=" +
feeEth(FEE_RESERVE_WEI) +
" sendDisabled=" +
st.sendDisabled +
" amountFeeError=" +
st.amountFeeError,
);
assert(
st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
"the screen is not quoting the estimate, so this send is not in the gap: " +
JSON.stringify(st.fee),
);
assert(
st.sendDisabled,
"Send is ENABLED for a transfer the fee RESERVE does not cover — the " +
"spend gate is reading the displayed estimate, which is issue #154",
);
assert(
st.amountFeeError,
"the amount-plus-fee message is not shown for a send the reserve does not cover",
);
assert(
st.height === env.ethPendingHeight,
"the over-budget state is a different height than the pending state: " +
env.ethPendingHeight +
"px -> " +
st.height +
"px",
);
});
test("ConfirmTx refuses a send that exceeds the balance outright (#238)", async (env) => {
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: OVER_BALANCE_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
const want =
"Insufficient balance. You have " +
FUNDED_ETH_TEXT +
" ETH but are trying to send " +
OVER_BALANCE_AMOUNT +
" ETH.";
assert(
st.errors === want,
"wrong over-balance message: " + JSON.stringify(st.errors),
);
assert(st.sendDisabled, "Send is enabled for a send that exceeds balance");
assert(
!st.amountFeeError,
"the amount-plus-fee message is shown for an amount that alone exceeds the balance",
);
});
test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => {
// The refusal is logged by confirmTx via log.errorf, i.e. console.error,
// which fails a test on its own. Declaring it here consumes exactly that
// one record — and fails this test if it never arrives.
env.errors.expect(
"confirmTx logging the failed gas estimate",
/gas estimation failed/,
);
env.routeOpts.failGasEstimate = true;
env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
const pending = await confirmState(env.page);
assert(
pending.fee === "Estimating..." && pending.sendDisabled,
"the screen is not in the pending state before the estimate fails",
);
env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page);
const st = await confirmState(env.page);
env.routeOpts.failGasEstimate = false;
assert(
st.fee === "Unable to estimate",
"the fee line does not report the failure: " + JSON.stringify(st.fee),
);
assert(
!st.reserveShown,
"the reserve line is shown after a failed estimate",
);
assert(
st.feeUnknownError,
"the estimate-failed message is not shown after a failed estimate",
);
assert(
!st.amountFeeError && !st.gasError && st.errors === "",
"a balance message is shown for an estimate that simply failed",
);
assert(
st.sendDisabled,
"Send is enabled with no usable fee estimate — an unknown fee is being treated as zero",
);
assert(
st.height === pending.height,
"the view changed height when the estimate failed: " +
pending.height +
"px -> " +
st.height +
"px",
);
});
test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (env) => {
env.routeOpts.holdGasEstimate = true;
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
const pending = await confirmState(env.page);
env.erc20PendingHeight = pending.height;
console.log("# confirm-tx ERC-20 view height: " + pending.height + "px");
assert(
pending.type === "ERC-20 token transfer (" + STUB_TOKEN.symbol + ")",
"unexpected transaction type: " + JSON.stringify(pending.type),
);
assert(
pending.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
"the ERC-20 screen shows the wrong balance: " +
JSON.stringify(pending.balance),
);
assert(
pending.fee === "Estimating..." && pending.sendDisabled,
"the ERC-20 screen does not block sending while its estimate is pending",
);
env.routeOpts.holdGasEstimate = false;
await waitForEstimate(env.page);
const st = await confirmState(env.page);
assert(
st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
"the ERC-20 fee line does not quote the estimate: " +
JSON.stringify(st.fee),
);
assert(
st.reserveShown &&
st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved",
"the ERC-20 fee block does not quote the reserve: " +
JSON.stringify(st.reserve),
);
assert(!st.sendDisabled, "Send is disabled for a funded ERC-20 transfer");
assert(
st.height === pending.height,
"the ERC-20 view changed height when the estimate landed: " +
pending.height +
"px -> " +
st.height +
"px",
);
});
test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", async (env) => {
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: OVER_TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
const want =
"Insufficient " +
STUB_TOKEN.symbol +
" balance. You have " +
TOKEN_BALANCE_TEXT +
" " +
STUB_TOKEN.symbol +
" but are trying to send " +
OVER_TOKEN_AMOUNT +
" " +
STUB_TOKEN.symbol +
".";
assert(
st.errors === want,
"wrong over-token-balance message: " + JSON.stringify(st.errors),
);
assert(
st.sendDisabled,
"Send is enabled for an ERC-20 transfer that exceeds the token balance",
);
assert(
!st.gasError,
"the ERC-20 gas message is shown for an ETH balance that covers the fee",
);
});
// The same swap, through the other balance and the other message: here the
// token balance is ample and it is the ETH balance that must cover the fee.
// It is set to exactly the estimate, so an estimate-reading gate lets this
// through and the reserve-reading gate refuses it.
test("ConfirmTx gates the ERC-20 fee on the RESERVE, with the ERC-20 message (#238)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FEE_ONLY_ETH_WEI);
await settleOnMain(env, {
ethWei: FEE_ONLY_ETH_WEI,
expectToken: true,
});
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
console.log(
"# erc-20 gate probe: ethBalance=" +
formatEther(FEE_ONLY_ETH_WEI) +
" estimate=" +
feeEth(FEE_ESTIMATE_WEI) +
" reserve=" +
feeEth(FEE_RESERVE_WEI) +
" sendDisabled=" +
st.sendDisabled +
" gasError=" +
st.gasError,
);
assert(
st.sendDisabled,
"Send is ENABLED for an ERC-20 transfer whose fee RESERVE exceeds the " +
"ETH balance — the spend gate is reading the displayed estimate (#154)",
);
assert(
st.gasError,
"the ERC-20 network-fee message is not shown when the ETH balance cannot cover the reserve",
);
assert(
!st.amountFeeError,
"the native-ETH over-budget message is shown on an ERC-20 transfer",
);
assert(
st.errors === "",
"a token-balance message is shown for a transfer the token balance covers: " +
JSON.stringify(st.errors),
);
assert(
st.height === env.erc20PendingHeight,
"the ERC-20 fee-error state is a different height than its pending state: " +
env.erc20PendingHeight +
"px -> " +
st.height +
"px",
);
});
test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee problem (#238)", async (env) => {
env.errors.expect(
"confirmTx logging the failed ERC-20 gas estimate",
/gas estimation failed/,
);
env.routeOpts.failGasEstimate = true;
await goToConfirm(env.page, {
token: STUB_TOKEN.address,
balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
const st = await confirmState(env.page);
env.routeOpts.failGasEstimate = false;
assert(
st.fee === "Unable to estimate",
"the ERC-20 fee line does not report the failure: " +
JSON.stringify(st.fee),
);
assert(
st.feeUnknownError,
"the estimate-failed message is not shown on the ERC-20 path",
);
assert(
!st.gasError,
"the ERC-20 network-fee message is shown for a fee that is unknown rather than unaffordable",
);
assert(
st.sendDisabled,
"Send is enabled on the ERC-20 path with no usable fee estimate",
);
assert(
st.height === env.erc20PendingHeight,
"the ERC-20 estimate-failed state is a different height than its pending state: " +
env.erc20PendingHeight +
"px -> " +
st.height +
"px",
);
});
// ------------------------------------------- dApp round trips (#183)
//
// The seam. Everything above drives the popup on its own; this section is
// the only place where a page, the content script, the inpage provider, the
// background worker and the approval popup all have to work together, and
// nothing else in the repo covers it — the unit suite covers each side in
// isolation.
//
// Three things make these tests worth more than "a call came back":
//
// - every signature is recovered here, in the runner, from the exact
// artifact the extension produced, and compared against the address read
// out of extension storage. The background verifies too (see
// src/shared/approvalVerify.js) but these assertions do not lean on it:
// a test that trusted the extension's own verdict would pass against a
// wallet that verified nothing.
// - the transaction assertions run against the raw signed transaction the
// background handed to eth_sendRawTransaction, captured by the route
// handler, not against anything the extension reported about it.
// - the password is required to be absent from the popup-to-background
// channel, observed directly, with the message that would carry it
// required to be present. That is the standing floor under the fix in
// https://git.eeqj.de/sneak/AutistMask/issues/157.
//
// What this does NOT cover, and must not be presented as covering: a real
// dApp, with real funds, against a real network. The RPC is stubbed
// throughout. That pass stays on the human list before 1.0.0.
const DAPP_HOSTNAME = new URL(DAPP_URL).hostname;
// The personal_sign payload. Sent as hex, which is what dApps send and what
// the popup requires — it calls getBytes() on the message — and displayed on
// the approval screen as the decoded text, which is what the user is agreeing
// to and therefore what the screen assertion checks.
const SIGN_TEXT = "AutistMask e2e round trip: personal_sign";
const SIGN_HEX = hexlify(toUtf8Bytes(SIGN_TEXT));
const TYPED_DOMAIN = {
name: "AutistMask e2e",
version: "1",
chainId: 1,
verifyingContract: STUB_COUNTERPARTY,
};
const TYPED_TYPES = {
Mail: [
{ name: "contents", type: "string" },
{ name: "amount", type: "uint256" },
],
};
const TYPED_MESSAGE = {
contents: "AutistMask e2e round trip: typed data",
amount: "1234",
};
// The wire form: EIP-712 payloads reach the wallet as a JSON string that
// carries EIP712Domain in `types`. ethers derives that entry itself and
// rejects it as an input, which is why the recovery below uses TYPED_TYPES
// and the payload here does not.
const TYPED_DATA_JSON = JSON.stringify({
domain: TYPED_DOMAIN,
types: Object.assign(
{
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
},
TYPED_TYPES,
),
primaryType: "Mail",
message: TYPED_MESSAGE,
});
// The transaction. Call data that decodes as nothing keeps the screen
// assertion honest: the raw data section is shown verbatim, so what is
// compared is the calldata itself rather than a decoder's summary of it.
const TX_VALUE_ETH = "0.0123";
const TX_VALUE_WEI = parseEther(TX_VALUE_ETH);
const TX_DATA = "0xdeadbeef" + "01".repeat(28);
const USER_REJECTION_MESSAGE = "User rejected the request.";
// The active address, read from extension storage rather than from any
// screen: it is the address the background will sign with, and it is what
// every recovery below is compared against.
async function extensionActiveAddress(page) {
const s = await page.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) =>
resolve(r.autistmask || null),
);
}),
);
assert(s !== null, "the extension has no persisted state to read");
const first =
s.wallets && s.wallets[0] && s.wallets[0].addresses[0]
? s.wallets[0].addresses[0].address
: null;
const address = s.activeAddress || first;
assert(address, "the extension holds no active address");
return getAddress(address);
}
async function openDapp(ctx) {
const page = await ctx.newPage();
await page.goto(DAPP_URL);
// window.ethereum is not the fixture's doing — it is the shipped
// MAIN-world content script. Waiting for it is waiting for the real
// provider to have injected itself into a real http(s) origin.
await page.waitForFunction(
() => !!window.ethereum && !!window.__dapp,
null,
{ timeout: 30000 },
);
return page;
}
function startRequest(page, key, method, params) {
return page.evaluate(
(a) => window.__dapp.start(a.key, a.method, a.params),
{ key, method, params },
);
}
// The settled outcome of a parked request, or {settled:"pending"} if it is
// still outstanding after `timeout`. A bounded wait rather than a bare await
// on purpose: "returns a rejection rather than hanging" is one of the things
// under test, and an await would report a hang as a suite timeout with no
// indication of which call never settled.
function settleRequest(page, key, timeout = 60000) {
return page.evaluate(
(a) =>
Promise.race([
window.__dapp.settle(a.key),
new Promise((resolve) => {
setTimeout(
() => resolve({ settled: "pending" }),
a.timeout,
);
}),
]),
{ key, timeout },
);
}
// Every AUTISTMASK_* message that has crossed between the test page and the
// content script so far, in both directions.
function dappMessages(page, type) {
return page.evaluate(
(want) =>
window.__dapp.messages.filter((m) => !want || m.type === want),
type || null,
);
}
// The approval window the background opened. Approvals are raised from an
// RPC call rather than from a user gesture, so the extension opens a real
// popup window for them; it is an ordinary page in this context.
async function waitForApprovalWindow(ctx, timeout = 30000) {
const deadline = Date.now() + timeout;
for (;;) {
const page = ctx
.pages()
.find((p) => !p.isClosed() && p.url().includes("?approval="));
if (page) return page;
if (Date.now() > deadline) {
throw new Error(
"the extension opened no approval window within " +
timeout +
"ms",
);
}
await sleep(50);
}
}
// A tab held ready for the site-connection prompt, reserved BEFORE the
// request that raises it and navigated — never replaced — once it has.
//
// The site connection is the one approval src/background/index.js raises
// through the toolbar-anchored popup: chrome.action.setPopup() followed by
// chrome.action.openPopup(), with a real window only as the fallback for an
// openPopup() that throws or rejects. Headless Chromium does open that popup,
// but Playwright cannot see it — it is not a page in ctx.pages() and never
// becomes one — so the prompt has to be driven at the URL the extension put
// on the action, which is the same page, the same approval id and the same
// code path the toolbar button shows, and the route README.md documents for
// reopening a pending approval.
//
// What must not happen is creating a page while that approval is pending.
// A new page dismisses the browser-action popup; the popup's approval port
// disconnects; src/background/index.js settles the approval as a rejection;
// and the prompt is gone before anything can be clicked. Measured directly:
// AUTISTMASK_GET_APPROVAL answers with the approval immediately before the
// tab is created and with null immediately after. Navigating a tab that
// already exists does not disturb it, which is the whole reason this
// reservation exists.
// How long the tab creation above is given to take effect before the request
// that raises the next prompt is issued. Dismissing a browser-action popup
// disconnects its port, and src/background/index.js calls resetPopupUrl() on
// that disconnect; issued too early, the next request's setPopup() is undone
// by the previous popup's teardown, chrome.action.openPopup() then rejects,
// and the fallback window it opens dismisses the popup that was just raised —
// which settles the fresh approval as a rejection before it can be seen.
// Every one of those steps was observed. 1.5s is comfortably past it.
const APPROVAL_TAB_SETTLE_MS = 1500;
async function reserveApprovalTab(env) {
if (env.approvalTab && !env.approvalTab.isClosed()) {
await env.approvalTab.close();
}
// Always a fresh page, and always before the request: creating it is what
// dismisses the stale browser-action popup left over from the previous
// approval, and doing that after the next one exists would take the next
// one down with it.
env.approvalTab = await env.ctx.newPage();
// The one accommodation this section makes to the shipped code, and the
// reason for it.
//
// Both approval buttons call runtime.sendMessage() and then window.close()
// on the next line. Closing this page disconnects the approval port, and
// the disconnect handler in src/background/index.js settles a pending
// site approval as a rejection. In a tab those two race and the teardown
// wins: the approve message is never acted on, and the page is told the
// user rejected. Measured — with the close left in place the approval
// resolves as a rejection every time; with it deferred it resolves as an
// approval every time.
//
// It is deferred, not removed: the harness closes the page itself once
// the outcome has been observed, which is what window.close() would have
// done, only after the message it was racing has been processed.
//
// This affects the site-connection prompt only. The sign and transaction
// prompts run in windows the extension opens itself, with window.close()
// untouched, and their disconnect handler deliberately keeps a tx or sign
// approval pending rather than rejecting it — so there is no race there
// to accommodate. Whether the same ordering holds in a real toolbar popup
// is not observable from a headless harness and is reported rather than
// assumed either way.
await env.approvalTab.addInitScript(() => {
window.close = function () {};
});
await env.approvalTab.goto("about:blank");
await sleep(APPROVAL_TAB_SETTLE_MS);
return env.approvalTab;
}
// The site-connection prompt: the window the extension opened if it managed
// to open one, and the reserved tab at the action's approval URL otherwise.
async function openSiteApprovalPopup(env, timeout = 30000) {
const deadline = Date.now() + timeout;
for (;;) {
const existing = env.ctx
.pages()
.find((p) => !p.isClosed() && p.url().includes("?approval="));
if (existing) return existing;
const url = await env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.action.getPopup({}, (u) => resolve(String(u)));
}),
);
if (url.includes("?approval=")) {
await env.approvalTab.goto(url);
return env.approvalTab;
}
if (Date.now() > deadline) {
throw new Error(
"no site approval prompt appeared within " +
timeout +
"ms; the browser action carried " +
JSON.stringify(url),
);
}
await sleep(50);
}
}
// Retire every approval page still open. A settled approval whose page is
// left behind would be found by the next waitForApprovalWindow() and driven
// as if it were the next approval.
async function closeApprovalPages(ctx) {
for (const page of ctx.pages()) {
if (!page.isClosed() && page.url().includes("?approval=")) {
await page.close();
}
}
}
// Record every message the approval window sends to the background worker.
//
// This is the direct observation the password check needs. It is installed
// after the approval screen has rendered and before Approve is clicked,
// which is the whole window in which a password could be put on the wire:
// the popup takes the password, derives the key, signs, and only then sends.
// The earlier AUTISTMASK_GET_APPROVAL exchange happens before the password
// field has been touched and carries nothing to leak.
//
// It wraps the property rather than the captured reference, which is what
// makes it see src/popup/views/approval.js's own `runtime.sendMessage` calls:
// that module captures the chrome.runtime object at load, not the function.
async function watchApprovalBoundary(page, env) {
const records = [];
await page.exposeFunction("__amRecordBoundary", (json) => {
records.push(JSON.parse(json));
env.boundaryRecords.push(JSON.parse(json));
});
const wrapped = await page.evaluate(() => {
const rt = chrome.runtime;
const original = rt.sendMessage.bind(rt);
rt.sendMessage = function (...args) {
let json;
try {
json = JSON.stringify(args[0]);
} catch (e) {
// A payload that will not serialize is still a payload, and
// recording nothing for it would be exactly the blind spot
// this observation exists to close.
json = JSON.stringify({ unserializable: String(e) });
}
const sent = window.__amRecordBoundary(json);
if (sent && typeof sent.catch === "function") {
// The window closes on a successful signature; a binding
// call still in flight then must not become a page error.
sent.catch(() => {});
}
return original.apply(rt, args);
};
return chrome.runtime.sendMessage !== original;
});
assert(
wrapped,
"could not wrap chrome.runtime.sendMessage in the approval window, so " +
"nothing was observed and the password assertion would be vacuous",
);
return records;
}
async function waitForBoundaryRecords(records, type, timeout = 30000) {
const deadline = Date.now() + timeout;
for (;;) {
const found = records.filter((r) => r && r.type === type);
if (found.length > 0) return found;
if (Date.now() > deadline) {
throw new Error(
"no " +
type +
" message was observed crossing the popup-to-background " +
"boundary, so the password assertion has nothing to assert on",
);
}
await sleep(25);
}
}
function assertNoPassword(records, where) {
const leaked = records.filter((r) => JSON.stringify(r).includes(PASSWORD));
assert(
leaked.length === 0,
"the password crossed the extension messaging boundary " +
where +
": " +
JSON.stringify(leaked),
);
}
// The most recent AUTISTMASK_RESPONSE the page received, which is where the
// EIP-1193 error code lives on the wire.
async function lastResponseError(page) {
const responses = await dappMessages(page, "AUTISTMASK_RESPONSE");
const last = responses[responses.length - 1];
assert(last, "the page received no AUTISTMASK_RESPONSE at all");
return last.error || null;
}
// A rejected prompt, asserted at both ends: the page's promise rejected
// rather than hanging or resolving, and the response that crossed the
// boundary carried EIP-1193 code 4001.
//
// The code is asserted on the wire because that is the only place it
// survives. src/content/inpage.js rebuilds the rejection as `new
// Error(error.message)`, so the Error the calling page catches carries the
// message and no code. That is reported rather than asserted either way —
// locking in the current behaviour would make the gap permanent, and
// asserting the code on the Error would fail today.
async function assertUserRejection(page, key, label) {
const outcome = await settleRequest(page, key);
assert(
outcome.settled !== "pending",
label + " never settled: the rejected prompt left the page hanging",
);
assert(
outcome.settled === "rejected",
label + " resolved instead of rejecting: " + JSON.stringify(outcome),
);
assert(
outcome.message === USER_REJECTION_MESSAGE,
label + " rejected with the wrong message: " + outcome.message,
);
const error = await lastResponseError(page);
assert(
error && error.code === 4001,
label +
" did not carry EIP-1193 code 4001 across the boundary: " +
JSON.stringify(error),
);
console.log(
"# " +
label +
": boundary code=" +
error.code +
" page Error.code=" +
JSON.stringify(outcome.code) +
" page Error carries a code=" +
outcome.hasCode,
);
return outcome;
}
test("the harness serves a page that gets the real inpage provider (#183)", async (env) => {
env.dapp = await openDapp(env.ctx);
env.expectedAddress = await extensionActiveAddress(env.page);
// EIP-6963, asked of the provider itself: the announcement has to name
// this extension and hand back the very object on window.ethereum. An
// identity check rather than a shape check, so nothing the fixture could
// have installed itself would satisfy it.
const announced = await env.dapp.evaluate(
() =>
new Promise((resolve) => {
const onAnnounce = (e) => {
window.removeEventListener(
"eip6963:announceProvider",
onAnnounce,
);
resolve({
rdns: e.detail.info.rdns,
name: e.detail.info.name,
isWindowEthereum: e.detail.provider === window.ethereum,
});
};
window.addEventListener("eip6963:announceProvider", onAnnounce);
window.dispatchEvent(new Event("eip6963:requestProvider"));
setTimeout(() => resolve(null), 10000);
}),
);
assert(announced, "the provider announced itself to no EIP-6963 request");
assert(
announced.rdns === "berlin.sneak.autistmask",
"the announced provider is not this extension: " +
JSON.stringify(announced),
);
assert(
announced.isWindowEthereum,
"the announced provider is not the object on window.ethereum",
);
// A full page -> content script -> background round trip that needs no
// approval, so the transport is proven before any prompt is driven.
const chainId = await env.dapp.evaluate(() =>
window.ethereum.request({ method: "eth_chainId" }),
);
assert(
chainId === "0x1",
"eth_chainId did not round trip through the extension: " +
JSON.stringify(chainId),
);
console.log(
"# dapp origin " +
DAPP_ORIGIN +
" active address " +
env.expectedAddress,
);
});
test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", async (env) => {
await reserveApprovalTab(env);
await startRequest(env.dapp, "accounts-reject", "eth_requestAccounts", []);
const popup = await openSiteApprovalPopup(env);
try {
await visible(popup, "#view-approve-site");
const hostname = await popup.locator("#approve-hostname").innerText();
assert(
hostname === DAPP_HOSTNAME,
"the site prompt names the wrong origin: " +
JSON.stringify(hostname),
);
// Deliberately not remembered: a remembered rejection lands the
// origin in deniedSites and every later test in this section is
// auto-rejected with no prompt at all, which would look like a pass.
await popup.uncheck("#approve-remember");
await popup.click("#btn-reject");
await assertUserRejection(
env.dapp,
"accounts-reject",
"eth_requestAccounts rejection",
);
} finally {
await closeApprovalPages(env.ctx);
}
});
test("eth_requestAccounts approved returns the selected address (#183)", async (env) => {
await reserveApprovalTab(env);
await startRequest(env.dapp, "accounts", "eth_requestAccounts", []);
const popup = await openSiteApprovalPopup(env);
let outcome;
try {
await visible(popup, "#view-approve-site");
const shown = await popup.locator("#approve-address").innerText();
assert(
shown.toLowerCase().includes(env.expectedAddress.toLowerCase()),
"the site prompt shows the wrong address: " + JSON.stringify(shown),
);
// Remembered, so the connection survives a background worker that MV3
// terminates after 30 seconds idle. The in-memory connectedSites map
// does not, and the sign and transaction tests below all require the
// origin to still be authorized.
await popup.check("#approve-remember");
await popup.click("#btn-approve");
outcome = await settleRequest(env.dapp, "accounts");
} finally {
await closeApprovalPages(env.ctx);
}
assert(
outcome.settled === "resolved",
"eth_requestAccounts did not resolve: " + JSON.stringify(outcome),
);
assert(
Array.isArray(outcome.result) && outcome.result.length === 1,
"eth_requestAccounts returned no single account: " +
JSON.stringify(outcome.result),
);
assert(
getAddress(outcome.result[0]) === env.expectedAddress,
"eth_requestAccounts returned " +
outcome.result[0] +
", not the selected address " +
env.expectedAddress,
);
});
test("personal_sign signs, and the signature recovers to the address (#183)", async (env) => {
await startRequest(env.dapp, "sign", "personal_sign", [
SIGN_HEX,
env.expectedAddress,
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
const boundary = await watchApprovalBoundary(popup, env);
const screen = await popup.evaluate(() => ({
hostname: document.getElementById("approve-sign-hostname").textContent,
type: document.getElementById("approve-sign-type").textContent,
message: document.getElementById("approve-sign-message").textContent,
from: document.getElementById("approve-sign-from").textContent,
}));
assert(
screen.hostname === DAPP_HOSTNAME,
"the sign prompt names the wrong origin: " +
JSON.stringify(screen.hostname),
);
assert(
screen.type === "Personal message",
"the sign prompt reports the wrong type: " +
JSON.stringify(screen.type),
);
assert(
screen.message === SIGN_TEXT,
"the sign prompt shows the wrong message: " +
JSON.stringify(screen.message),
);
assert(
screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()),
"the sign prompt shows the wrong signing address: " +
JSON.stringify(screen.from),
);
await popup.fill("#approve-sign-password", PASSWORD);
await popup.click("#btn-approve-sign");
const outcome = await settleRequest(env.dapp, "sign");
assert(
outcome.settled === "resolved",
"personal_sign did not resolve: " + JSON.stringify(outcome),
);
const recovered = getAddress(
verifyMessage(getBytes(SIGN_HEX), outcome.result),
);
console.log(
"# personal_sign: recovered=" +
recovered +
" expected=" +
env.expectedAddress,
);
assert(
recovered === env.expectedAddress,
"the personal_sign signature recovers to " +
recovered +
", not to the approved address " +
env.expectedAddress,
);
const sent = await waitForBoundaryRecords(
boundary,
"AUTISTMASK_SIGN_RESPONSE",
);
assert(
sent.length === 1 && sent[0].approved === true,
"the popup did not send exactly one approved sign response: " +
JSON.stringify(sent),
);
assert(
sent[0].signature === outcome.result,
"the signature the page received is not the one the popup produced",
);
assertNoPassword(boundary, "on the personal_sign approval");
});
test("personal_sign rejected returns a rejection to the page (#183)", async (env) => {
await startRequest(env.dapp, "sign-reject", "personal_sign", [
SIGN_HEX,
env.expectedAddress,
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
await popup.click("#btn-reject-sign");
await assertUserRejection(
env.dapp,
"sign-reject",
"personal_sign rejection",
);
});
test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => {
await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [
env.expectedAddress,
TYPED_DATA_JSON,
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
const boundary = await watchApprovalBoundary(popup, env);
const screen = await popup.evaluate(() => ({
hostname: document.getElementById("approve-sign-hostname").textContent,
type: document.getElementById("approve-sign-type").textContent,
message: document.getElementById("approve-sign-message").innerText,
from: document.getElementById("approve-sign-from").textContent,
}));
assert(
screen.hostname === DAPP_HOSTNAME,
"the typed data prompt names the wrong origin: " +
JSON.stringify(screen.hostname),
);
assert(
screen.type === "Typed data (EIP-712)",
"the typed data prompt reports the wrong type: " +
JSON.stringify(screen.type),
);
for (const want of [
TYPED_DOMAIN.name,
"Mail",
TYPED_MESSAGE.contents,
TYPED_MESSAGE.amount,
]) {
assert(
screen.message.includes(want),
"the typed data prompt does not show " +
JSON.stringify(want) +
", got: " +
JSON.stringify(screen.message),
);
}
assert(
screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()),
"the typed data prompt shows the wrong signing address: " +
JSON.stringify(screen.from),
);
await popup.fill("#approve-sign-password", PASSWORD);
await popup.click("#btn-approve-sign");
const outcome = await settleRequest(env.dapp, "typed");
assert(
outcome.settled === "resolved",
"eth_signTypedData_v4 did not resolve: " + JSON.stringify(outcome),
);
const recovered = getAddress(
verifyTypedData(
TYPED_DOMAIN,
TYPED_TYPES,
TYPED_MESSAGE,
outcome.result,
),
);
console.log(
"# eth_signTypedData_v4: recovered=" +
recovered +
" expected=" +
env.expectedAddress,
);
assert(
recovered === env.expectedAddress,
"the typed data signature recovers to " +
recovered +
", not to the approved address " +
env.expectedAddress,
);
const sent = await waitForBoundaryRecords(
boundary,
"AUTISTMASK_SIGN_RESPONSE",
);
assert(
sent.length === 1 && sent[0].signature === outcome.result,
"the popup did not send exactly one sign response carrying this signature: " +
JSON.stringify(sent),
);
assertNoPassword(boundary, "on the eth_signTypedData_v4 approval");
});
test("eth_signTypedData_v4 rejected returns a rejection to the page (#183)", async (env) => {
await startRequest(env.dapp, "typed-reject", "eth_signTypedData_v4", [
env.expectedAddress,
TYPED_DATA_JSON,
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
await popup.click("#btn-reject-sign");
await assertUserRejection(
env.dapp,
"typed-reject",
"eth_signTypedData_v4 rejection",
);
});
test("eth_sendTransaction signs the approved transaction and broadcasts it (#183)", async (env) => {
const txParams = {
from: env.expectedAddress,
to: STUB_COUNTERPARTY,
value: toQuantity(TX_VALUE_WEI),
data: TX_DATA,
};
await startRequest(env.dapp, "tx", "eth_sendTransaction", [txParams]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-tx");
const boundary = await watchApprovalBoundary(popup, env);
const screen = await popup.evaluate(() => ({
hostname: document.getElementById("approve-tx-hostname").textContent,
from: document.getElementById("approve-tx-from").textContent,
to: document.getElementById("approve-tx-to").textContent,
value: document.getElementById("approve-tx-value").textContent,
data: document.getElementById("approve-tx-data").textContent,
dataShown: !document
.getElementById("approve-tx-data-section")
.classList.contains("hidden"),
}));
assert(
screen.hostname === DAPP_HOSTNAME,
"the transaction prompt names the wrong origin: " +
JSON.stringify(screen.hostname),
);
assert(
screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()),
"the transaction prompt shows the wrong sender: " +
JSON.stringify(screen.from),
);
assert(
screen.to.toLowerCase().includes(STUB_COUNTERPARTY.toLowerCase()),
"the transaction prompt shows the wrong recipient: " +
JSON.stringify(screen.to),
);
assert(
screen.value.startsWith(TX_VALUE_ETH + " ETH"),
"the transaction prompt shows the wrong value: " +
JSON.stringify(screen.value),
);
assert(
screen.dataShown && screen.data === TX_DATA,
"the transaction prompt does not show the approved call data: " +
JSON.stringify(screen.data),
);
const broadcastBefore = env.routeOpts.broadcastTransactions.length;
await popup.fill("#approve-tx-password", PASSWORD);
await popup.click("#btn-approve-tx");
const outcome = await settleRequest(env.dapp, "tx");
assert(
outcome.settled === "resolved",
"eth_sendTransaction did not resolve: " + JSON.stringify(outcome),
);
// The artifact as the node saw it, not as the extension described it.
const broadcast = env.routeOpts.broadcastTransactions;
assert(
broadcast.length === broadcastBefore + 1,
"expected exactly one raw transaction to reach the RPC, got " +
(broadcast.length - broadcastBefore),
);
const signed = Transaction.from(broadcast[broadcast.length - 1]);
console.log(
"# eth_sendTransaction: signer=" +
getAddress(signed.from) +
" expected=" +
env.expectedAddress +
" to=" +
getAddress(signed.to) +
" value=" +
formatEther(signed.value) +
" chainId=" +
signed.chainId,
);
assert(
getAddress(signed.from) === env.expectedAddress,
"the broadcast transaction was signed by " +
getAddress(signed.from) +
", not by the approved address " +
env.expectedAddress,
);
assert(
getAddress(signed.to) === getAddress(STUB_COUNTERPARTY),
"the broadcast transaction goes to " + signed.to,
);
assert(
signed.value === TX_VALUE_WEI,
"the broadcast transaction carries " +
formatEther(signed.value) +
" ETH, not the approved " +
TX_VALUE_ETH,
);
assert(
signed.data === TX_DATA,
"the broadcast transaction carries different call data: " + signed.data,
);
assert(
signed.chainId === 1n,
"the broadcast transaction is for chain " + signed.chainId,
);
assert(
outcome.result === signed.hash,
"the page received " +
outcome.result +
", not the hash of the broadcast transaction " +
signed.hash,
);
const sent = await waitForBoundaryRecords(
boundary,
"AUTISTMASK_TX_RESPONSE",
);
assert(
sent.length === 1 &&
sent[0].approved === true &&
typeof sent[0].rawSignedTx === "string",
"the popup did not send exactly one approved transaction response: " +
JSON.stringify(sent),
);
assertNoPassword(boundary, "on the eth_sendTransaction approval");
// The approval window hands off to the wait screen rather than closing,
// and it is this run's job to close it: left open it keeps polling for a
// receipt for the rest of the suite.
await visible(popup, "#view-wait-tx");
const waitHash = await popup.locator("#wait-tx-hash").innerText();
assert(
waitHash.includes(signed.hash),
"the wait screen shows a different hash: " + JSON.stringify(waitHash),
);
await popup.close();
});
test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
const before = env.routeOpts.broadcastTransactions.length;
await startRequest(env.dapp, "tx-reject", "eth_sendTransaction", [
{
from: env.expectedAddress,
to: STUB_COUNTERPARTY,
value: toQuantity(TX_VALUE_WEI),
data: TX_DATA,
},
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-tx");
await popup.click("#btn-reject-tx");
await assertUserRejection(
env.dapp,
"tx-reject",
"eth_sendTransaction rejection",
);
assert(
env.routeOpts.broadcastTransactions.length === before,
"a rejected transaction still reached the RPC",
);
});
// The closing pass over both boundaries at once. Every message the section
// put on either channel is re-read here and required to be free of the
// password — and required to be there at all, method by method, so the
// assertion cannot pass by having observed nothing.
test("the password never crossed either boundary in this section (#183)", async (env) => {
const messages = await dappMessages(env.dapp);
const requested = messages
.filter((m) => m.type === "AUTISTMASK_REQUEST")
.map((m) => m.method);
for (const method of [
"eth_requestAccounts",
"personal_sign",
"eth_signTypedData_v4",
"eth_sendTransaction",
]) {
assert(
requested.includes(method),
"no " +
method +
" was observed crossing the page boundary, so this check " +
"is asserting on an incomplete record: " +
JSON.stringify(requested),
);
}
assert(
env.boundaryRecords.length >= 3,
"fewer popup-to-background messages were observed than the three " +
"approvals that were signed: " +
env.boundaryRecords.length,
);
console.log(
"# boundary observation: " +
messages.length +
" page/content-script messages, " +
env.boundaryRecords.length +
" popup/background messages, " +
requested.length +
" requests",
);
assertNoPassword(messages, "between the page and the content script");
assertNoPassword(
env.boundaryRecords,
"between the popup and the background",
);
await env.dapp.close();
});
// ---------------------------------------------------------------- runner
async function main() {
// A suite that runs nothing must never report success. If a refactor
// drops the registrations above, or a require() of this file stops
// reaching them, the only honest outcome is a red run — reporting
// "0/0 passed" and exiting 0 is the same vacuous-check failure this
// whole harness exists to prevent.
if (tests.length === 0) {
console.log("1..0");
console.log("# FAILED: the e2e suite registered no tests");
process.exitCode = 1;
return;
}
// Every fixture switch the suite can flip, declared in one place so the
// starting state of a run is readable without hunting through tests.
const routeOpts = {
seedTokenTransfer: false,
seedTokenBalance: false,
ethBalanceWei: null,
failGasEstimate: false,
holdGasEstimate: false,
// Every raw signed transaction handed to eth_sendRawTransaction, in
// order. The dApp transaction round trip asserts against these bytes
// rather than against anything the extension reported about them.
broadcastTransactions: [],
};
let session;
try {
session = await launch(routeOpts);
} catch (e) {
// Never skip and report success: a browser we cannot start, or
// one whose network interception is not in force, is a failure of
// the suite, not an absent one.
console.error("e2e: cannot run the suite: " + 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,
// The error collector, so a test that drives a failure path on
// purpose can declare the console.error it is about to provoke.
errors: session.errors,
// The recovery phrase of the wallet created in test 2, so later
// tests can assert on the real secret rather than its shape.
phrase: null,
// Confirmation-screen heights, measured in the pending state and
// compared against every later state of the same screen.
ethPendingHeight: null,
erc20PendingHeight: null,
// The dApp round trips: the test page, the address every signature
// must recover to, and every message observed leaving an approval
// window for the background worker.
dapp: null,
approvalTab: null,
expectedAddress: null,
boundaryRecords: [],
};
// Attribution of collected errors is total. session.errors has no
// window API at all: take() always drains everything outstanding, so
// successive takes partition the whole stream, and the phases below
// cover the entire life of the run. Nothing the collector holds can
// go unread.
//
// launch .. end of test 1 -> test 1 (so the worker's startup
// fetches land on a test, not
// nowhere)
// end of test k .. end of k+1 -> test k+1
// last test .. teardown -> the suite, via the trailing drain
//
// Those three phases cover the entire life of the browser context.
// There is no fourth: once the context is closed nothing can record,
// because the route handler and the console listeners died with it.
// Traffic that a test defers past the trailing drain is therefore
// never observed at all — a real limit of this design, stated in the
// README, and not one any post-teardown hook could close.
//
// Two green-but-vacuous runs on this harness were the same shape: a
// record falling outside somebody's window and being dropped. First
// the mark started after test 1, discarding launch-time records;
// then the tail after the last test was never read. Patching a
// second boundary would have invited a third, so the window concept
// is gone rather than fixed.
let failed = 0;
let n = 0;
for (const t of tests) {
n += 1;
let failure = null;
try {
await withTimeout(t.fn(env), t.name);
} catch (e) {
failure = e.message;
}
// Any uncaught page error, console.error or unstubbed request
// fails the test that provoked it, whether or not its assertions
// passed. This is the mechanism that caught #150.
const newErrors = session.errors.take();
if (!failure && newErrors.length > 0) {
failure = "uncaught browser errors during this test";
}
// A test that declared an error it meant to provoke and did not
// provoke it asserted nothing. Failing here is what keeps expect()
// from being usable as a mute.
const unmatched = session.errors.unmatchedExpectations();
if (!failure && unmatched.length > 0) {
failure =
"expected browser error(s) that never arrived: " +
unmatched.join("; ");
}
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);
}
}
// Keep watching after the last test returns, before tearing the
// browser down. A request a test fires without awaiting is still in
// flight when its function resolves; measured here it reaches the
// route handler about 10ms later, but closing the context does not
// wait for it — with no window at all the request dies unobserved
// and the run goes green, which is exactly how escaping traffic
// stays invisible.
//
// A fixed bounded window rather than a quiescence poll on purpose:
// the collector being quiet is not evidence, because a request that
// has not been dispatched yet has recorded nothing to be quiet
// about. Playwright offers no "is anything in flight" question to
// ask either — the route handler is the only observation point — so
// a grace period is the mechanism available, and this one is ~150x
// the measured latency for 1.5s on a ~25s suite.
await new Promise((resolve) => setTimeout(resolve, TRAILING_WATCH_MS));
await session.close();
// The tail. These cannot be blamed on any single test, so they are
// reported against the suite rather than guessed at — but they are
// reported, and they fail the run.
const trailing = session.errors.take();
console.log(
"# " + (tests.length - failed) + "/" + tests.length + " tests passed",
);
if (trailing.length > 0) {
console.log(
"# " +
trailing.length +
" browser error(s) recorded after the last test finished, " +
"not attributable to any single test:",
);
for (const line of trailing) {
console.log("# " + line);
}
}
if (failed > 0 || trailing.length > 0) {
console.log("# FAILED");
process.exitCode = 1;
}
}
main().catch((e) => {
console.error("e2e: " + (e && e.stack ? e.stack : e));
process.exitCode = 1;
});