All checks were successful
check / check (push) Successful in 34s
goBack() only unhid its target. A reopened popup renders the wallet list and the one view restoreView() lands on, so every other view is still the blank static template from index.html: pressing Back from Settings onto an address showed an empty address line and no balances, and the same held for address-token, receive, confirm-tx and transaction. The per-view dispatch and its data guards move out of restoreView() into src/popup/viewRouter.js, and goBack() now routes a popped view through the same code by way of a renderer index.js registers with setBackRenderer(). A view whose backing state is gone falls back to Home the way the restore does, rather than showing an empty template. The Back path renders only a view this page load has not rendered yet. viewRouter.js keeps a page-load-scoped set of rendered views, written by showView() — the last thing every render path runs, forward navigation and the restore alike, so a view added later registers itself rather than needing to be remembered. Back onto a view already in that set unhides it and nothing more, exactly as it does for a view outside RESTORABLE_VIEWS: rendering a second time would re-fetch and overwrite what the view holds, such as an edit typed into Settings and not yet saved. Home stays the exception and is re-rendered on every Back, as it was when goBack() called the renderWalletList() registered through setRenderMain(), so the wallet list reflects what changed while the user was away from it. tests/backNavigation.test.js drives the real goBack() over the reproduction and over each of address-token, receive, confirm-tx, transaction, success-tx and error-tx, with and without their backing state, and pins that a view this page load already rendered — a live-session view, or Settings revisited — is only unhidden, while Home still re-renders. tests/e2e/run.js adds three cases against the real popup in a real browser, because make check cannot see a blank view: a real close and reopen then Back onto the address screen and onto Receive, and an in-session Back onto Settings that must keep unsaved input. All three were demonstrated failing first.
2523 lines
94 KiB
JavaScript
2523 lines
94 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");
|
|
});
|
|
|
|
// ------------------------------- Back after reopening the popup (#268)
|
|
|
|
// A reopened popup renders the wallet list and the view it restores onto,
|
|
// and nothing else: every other screen is still the blank static template
|
|
// from index.html. Back used to only unhide its target, which is why these
|
|
// have to run against the real popup — the template is present and
|
|
// well-formed, so only its emptiness distinguishes the defect, and only a
|
|
// real reopen produces it.
|
|
|
|
// Everything the address screen must have on it, read out of the DOM.
|
|
function addressScreenState(page) {
|
|
return page.evaluate(() => {
|
|
const line = document.getElementById("address-line");
|
|
const balances = document.getElementById("address-balances");
|
|
return {
|
|
hidden: document
|
|
.getElementById("view-address")
|
|
.classList.contains("hidden"),
|
|
line: line ? line.innerText.trim() : "",
|
|
balances: balances ? balances.innerText.trim() : "",
|
|
};
|
|
});
|
|
}
|
|
|
|
// Close and reopen the page rather than reload it: that is what the toolbar
|
|
// popup does, and it is the only thing that produces the unrendered views.
|
|
async function reopenPopup(env, restoredView) {
|
|
await env.page.close();
|
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
|
await visible(env.page, restoredView);
|
|
}
|
|
|
|
// The reproduction from the issue, step for step.
|
|
test("Back after reopening the popup renders the address screen (#268)", async (env) => {
|
|
await openAddressDetail(env.page);
|
|
const before = await addressScreenState(env.page);
|
|
assert(
|
|
before.line.length > 0,
|
|
"the address screen was blank to begin with",
|
|
);
|
|
|
|
await env.page.click("#btn-settings");
|
|
await visible(env.page, "#view-settings");
|
|
|
|
await reopenPopup(env, "#view-settings");
|
|
|
|
await env.page.click("#btn-settings-back");
|
|
await visible(env.page, "#view-address");
|
|
|
|
const after = await addressScreenState(env.page);
|
|
assert(
|
|
after.line === before.line,
|
|
"the address line reads " +
|
|
JSON.stringify(after.line) +
|
|
", expected " +
|
|
JSON.stringify(before.line),
|
|
);
|
|
assert(
|
|
after.balances.includes("ETH"),
|
|
"the balances read " + JSON.stringify(after.balances),
|
|
);
|
|
});
|
|
|
|
// The same defect one screen further in. Receive holds the address twice
|
|
// over — as text and as the QR code the sender scans — and a blank one is
|
|
// worse than a missing screen.
|
|
// Everything the Receive screen must have on it. The QR code is read as
|
|
// pixels, not as an element: the blank template carries the canvas too, a
|
|
// default 300x150 one with nothing drawn on it and every pixel fully
|
|
// transparent. A drawn QR paints an opaque background across the whole
|
|
// canvas, so a single opaque pixel is the whole question.
|
|
function receiveScreenState(page) {
|
|
return page.evaluate(() => {
|
|
const block = document.getElementById("receive-address-block");
|
|
const canvas = document.getElementById("receive-qr");
|
|
const px = canvas
|
|
.getContext("2d")
|
|
.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
let opaque = 0;
|
|
for (let i = 3; i < px.length; i += 4) {
|
|
if (px[i] > 0) opaque += 1;
|
|
}
|
|
return {
|
|
address: block.dataset.full || "",
|
|
text: block.innerText.trim(),
|
|
qrOpaquePixels: opaque,
|
|
};
|
|
});
|
|
}
|
|
|
|
test("Back after reopening the popup renders the Receive screen (#268)", async (env) => {
|
|
await openAddressDetail(env.page);
|
|
await env.page.click("#btn-receive");
|
|
await visible(env.page, "#view-receive");
|
|
const before = await receiveScreenState(env.page);
|
|
assert(
|
|
/^0x[0-9a-fA-F]{40}$/.test(before.address),
|
|
"Receive showed no address to begin with: " +
|
|
JSON.stringify(before.address),
|
|
);
|
|
|
|
await env.page.click("#btn-settings");
|
|
await visible(env.page, "#view-settings");
|
|
|
|
await reopenPopup(env, "#view-settings");
|
|
|
|
await env.page.click("#btn-settings-back");
|
|
await visible(env.page, "#view-receive");
|
|
|
|
const shown = await receiveScreenState(env.page);
|
|
assert(
|
|
shown.address === before.address,
|
|
"Receive shows " +
|
|
JSON.stringify(shown.address) +
|
|
", expected " +
|
|
JSON.stringify(before.address),
|
|
);
|
|
assert(
|
|
shown.text.includes(before.address),
|
|
"the Receive address is not on screen: " + JSON.stringify(shown.text),
|
|
);
|
|
assert(shown.qrOpaquePixels > 0, "Receive shows an unpainted QR code");
|
|
|
|
// Leave the suite where it found it.
|
|
await env.page.click("#btn-receive-back");
|
|
await visible(env.page, "#view-address");
|
|
await env.page.click("#btn-address-back");
|
|
await visible(env.page, "#view-main");
|
|
});
|
|
|
|
// The other half of the requirement: Back renders a screen this page load
|
|
// never rendered, and must NOT re-render one it already has on screen.
|
|
// settings.show() reassigns #settings-rpc from persisted state, so
|
|
// re-rendering Settings on the way back would silently revert whatever the
|
|
// user typed and had not saved yet — and they could then press Save and
|
|
// store the value they believed they had replaced. No reopen here: this is
|
|
// an ordinary in-session forward-and-back, which is exactly why the render
|
|
// must not happen.
|
|
test("Back onto Settings keeps unsaved input (#268)", async (env) => {
|
|
await visible(env.page, "#view-main");
|
|
await env.page.click("#btn-settings");
|
|
await visible(env.page, "#view-settings");
|
|
|
|
const typed = "https://rpc.example.invalid/unsaved";
|
|
await env.page.fill("#settings-rpc", typed);
|
|
|
|
await env.page.click("#btn-settings-add-token");
|
|
await visible(env.page, "#view-settings-addtoken");
|
|
await env.page.click("#btn-settings-addtoken-back");
|
|
await visible(env.page, "#view-settings");
|
|
|
|
const kept = await env.page.inputValue("#settings-rpc");
|
|
assert(
|
|
kept === typed,
|
|
"the unsaved RPC URL reads " +
|
|
JSON.stringify(kept) +
|
|
", expected " +
|
|
JSON.stringify(typed),
|
|
);
|
|
|
|
// Leave the suite where it found it. The typed value was never saved,
|
|
// and Settings reloads the field from state next time it renders.
|
|
await env.page.click("#btn-settings-back");
|
|
await visible(env.page, "#view-main");
|
|
});
|
|
|
|
// -------------------------------------------- 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;
|
|
});
|