// 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(); // This tab runs the shipped popup with nothing patched. The site // approval buttons decide and then close on the next line, and the two // site-approval tests below are therefore the real-browser // approve-then-immediate-close and reject-then-immediate-close cases: the // decision rides the approval port, which also carries the disconnect the // close causes, so it is delivered ahead of it and the outcome does not // depend on the teardown timing (#275). 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(); } } } // Click a button whose own handler closes the window it lives in — every // Reject, and Allow on the site prompt. // // page.click() dispatches the click and then waits for the renderer to // acknowledge it, and a page torn down by the handler never gets to. The // dispatch is what the test needs and the log shows it happening ("performing // click action") immediately before the failure; the page going away is the // button working, not the click failing. Observed on #btn-reject-sign and // #btn-reject-tx, whose windows have always closed themselves. // // This swallows nothing that matters: a click that did not land leaves the // dApp promise unsettled and the assertion after the call still fails. A // button that is missing or unclickable raises a different error, which is // rethrown. async function clickAndClose(page, selector) { try { await page.click(selector); } catch (e) { if (!String((e && e.message) || e).includes("has been closed")) throw e; } } // 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 EIP-1193 code 4001 is present both // on the wire and on the Error the calling page catches. // // Both ends matter because they used to disagree. The code crossed the // boundary correctly and src/content/inpage.js then threw it away, rebuilding // every rejection as `new Error(error.message)` — so a dApp branching on // `err.code === 4001` saw undefined and could not tell a refusal from a // failure (#274). Asserting only the wire would leave that gap invisible. 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), ); assert( outcome.hasCode, label + " reached the page as an error with no code property at all, so a " + "dApp cannot tell the user's refusal from a failure: " + JSON.stringify(outcome), ); assert( outcome.code === 4001, label + " reached the page with code " + JSON.stringify(outcome.code) + " rather than EIP-1193 4001", ); assert( outcome.name === "ProviderRpcError", label + " reached the page as " + JSON.stringify(outcome.name) + " rather than an EIP-1193 ProviderRpcError", ); console.log( "# " + label + ": code 4001 on the wire and on the page's " + outcome.name, ); 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 clickAndClose(popup, "#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 clickAndClose(popup, "#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 clickAndClose(popup, "#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 clickAndClose(popup, "#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 clickAndClose(popup, "#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; });