// 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,
    formatUnits,
    getAddress,
    getBytes,
    hexlify,
    parseEther,
    parseUnits,
    toQuantity,
    toUtf8Bytes,
    verifyMessage,
    verifyTypedData,
} = require("ethers");
const {
    PASSWORD,
    createWallet,
    launch,
    openAddressDetail,
    openPopup,
    pageCompilesWasm,
    visible,
} = require("./harness");
const {
    DAPP_ORIGIN,
    DAPP_URL,
    PHISHING_DAPP_URL,
    FEE_ESTIMATE_WEI,
    FEE_RESERVE_WEI,
    STUB_COUNTERPARTY,
    STUB_NATIVE_TX_HASH,
    STUB_NATIVE_VALUE_WEI,
    STUB_TOKEN,
    STUB_TX_HASH,
} = require("./network");
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
const { NETWORKS } = require("../../src/shared/networks");

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(
                        name + ": 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");
});

// --------------------- the rest of the #150 and #151 definition of done
//
// The two tests above assert that the screens #150 and #151 broke now open
// without throwing, which is narrower than what those issues asked for.
// The four items below are the remainder (#188): the navigation stack out
// of Add Token, the quick-pick actually populating the field, the native
// ETH detail path the ERC-20 fix could have regressed, and tap-to-copy.

// Leave the transaction detail screen for the address screen it was opened
// from. The two tests above finish on it, and so does the last test here.
async function leaveTransactionDetail(page) {
    if (await page.isVisible("#view-transaction")) {
        await page.click("#btn-tx-back");
    }
    await openAddressDetail(page);
}

// Back out to Home from wherever the previous test finished.
async function goHome(page) {
    await leaveTransactionDetail(page);
    await page.click("#btn-address-back");
    await visible(page, "#view-main");
}

// The navigation stack as it was actually persisted, read out of extension
// storage rather than inferred from which screen is showing. A stale entry
// left behind by a forward navigation that threw is invisible on screen
// until the user presses Back one time too many — which is exactly the
// second-order damage #150 did — so the stack itself is what gets asserted.
function persistedViewStack(page) {
    return page.evaluate(
        () =>
            new Promise((resolve) => {
                chrome.storage.local.get("autistmask", (r) => {
                    resolve((r.autistmask && r.autistmask.viewStack) || []);
                });
            }),
    );
}

// saveState() is fired from showView() without being awaited, so the write
// lands shortly after the screen does. Polling for the expected stack keeps
// that race out of the assertion; a stack that never becomes the expected
// one fails with what it actually was.
const VIEW_STACK_SETTLE_MS = 5000;

async function waitForViewStack(page, expected, where) {
    const want = JSON.stringify(expected);
    const deadline = Date.now() + VIEW_STACK_SETTLE_MS;
    let seen;
    for (;;) {
        seen = await persistedViewStack(page);
        if (JSON.stringify(seen) === want) return;
        if (Date.now() >= deadline) break;
        await sleep(50);
    }
    throw new Error(
        "navigation stack " +
            where +
            " is " +
            JSON.stringify(seen) +
            ", expected " +
            want,
    );
}

// The invariant is stated as a delta against whatever the earlier tests
// left on the stack, not as an absolute: a round trip into Add Token and
// back out must leave the stack exactly as it found it. That is what "no
// duplicated or orphaned stack entry" means, and it holds whatever the
// starting depth is.
test("Back from Add Token unwinds the stack exactly once (#150)", async (env) => {
    await goHome(env.page);
    const base = await persistedViewStack(env.page);

    await env.page.locator("#wallet-list .btn-addr-info").first().click();
    await visible(env.page, "#view-address");
    await waitForViewStack(env.page, base.concat("main"), "on address detail");

    await env.page.click("#btn-add-token");
    await visible(env.page, "#view-add-token");
    await waitForViewStack(
        env.page,
        base.concat("main", "address"),
        "on the add token screen",
    );

    await env.page.click("#btn-add-token-back");
    await visible(env.page, "#view-address");
    assert(
        !(await env.page.isVisible("#view-add-token")),
        "the add token screen is still showing after Back",
    );
    await waitForViewStack(
        env.page,
        base.concat("main"),
        "after Back from add token",
    );

    await env.page.click("#btn-address-back");
    await visible(env.page, "#view-main");
    await waitForViewStack(env.page, base, "after a second Back");
});

test("a common-token quick-pick fills in the contract address (#150)", async (env) => {
    await openAddressDetail(env.page);
    await env.page.click("#btn-add-token");
    await visible(env.page, "#view-add-token");

    const before = await env.page.inputValue("#add-token-address");
    assert(
        before === "",
        "the add token screen opened with the address field already filled: " +
            JSON.stringify(before),
    );

    const pick = env.page.locator("#common-token-list .common-token").first();
    const wanted = await pick.getAttribute("data-address");
    assert(
        /^0x[0-9a-fA-F]{40}$/.test(wanted || ""),
        "the first quick-pick button carries no contract address: " +
            JSON.stringify(wanted),
    );

    await pick.click();
    const after = await env.page.inputValue("#add-token-address");
    assert(
        after === wanted,
        "clicking the " +
            (await pick.innerText()).trim() +
            " quick-pick left the address field as " +
            JSON.stringify(after) +
            ", expected " +
            JSON.stringify(wanted),
    );

    await env.page.click("#btn-add-token-back");
    await visible(env.page, "#view-address");
});

// The native amount as the transaction list writes it (four decimals) and
// as the detail screen writes it (full precision). Both are rendered here
// from the fixture rather than read off the screen, so the assertions
// compare against the wei the stub served.
const NATIVE_ROW_TEXT =
    parseFloat(formatEther(STUB_NATIVE_VALUE_WEI)).toFixed(4) + " ETH";
const NATIVE_DETAIL_TEXT = formatEther(STUB_NATIVE_VALUE_WEI) + " ETH";

test("the native ETH transaction detail still renders (#151)", async (env) => {
    // The ERC-20 fix could only have regressed this path by making the
    // token-contract branch run for a transfer that has no contract, so
    // the assertions below are as much about that row staying hidden as
    // about the screen coming up.
    env.routeOpts.seedNativeTransfer = true;
    await env.page.reload();
    await openAddressDetail(env.page);

    const row = env.page
        .locator("#tx-list .tx-row")
        .filter({ hasText: NATIVE_ROW_TEXT });
    await row.waitFor({ state: "visible", timeout: 30000 });
    await row.click();
    await visible(env.page, "#view-transaction");

    const hash = await env.page.locator("#tx-detail-hash").innerText();
    assert(
        hash.includes(STUB_NATIVE_TX_HASH),
        "the native transaction detail shows the wrong hash: " + hash,
    );

    const type = (await env.page.locator("#tx-detail-type").innerText()).trim();
    assert(
        type === "Native ETH Transfer",
        "the native transaction was classified " + JSON.stringify(type),
    );

    const value = await env.page.locator("#tx-detail-value").innerText();
    assert(
        value.includes(NATIVE_DETAIL_TEXT),
        "the native transaction detail shows " +
            JSON.stringify(value) +
            ", expected it to contain " +
            NATIVE_DETAIL_TEXT,
    );

    const native = await env.page.locator("#tx-detail-native").innerText();
    assert(
        native.includes(STUB_NATIVE_VALUE_WEI + " wei"),
        "the raw quantity row shows " +
            JSON.stringify(native) +
            ", expected the value in wei",
    );

    assert(
        !(await env.page.isVisible("#tx-detail-token-contract-section")),
        "the token contract row is showing on a transfer that has no token " +
            "contract",
    );

    // Back to one seeded transaction for everything after this: the tests
    // below were written against a list holding the token transfer alone.
    env.routeOpts.seedNativeTransfer = false;
});

test("tap-to-copy on the transaction detail screen copies the address (#151)", async (env) => {
    // Read the clipboard back rather than watching the handler run: what
    // #151 asks for is the address reaching the clipboard, and a spy on
    // navigator.clipboard would assert the call and not the effect.
    //
    // Granted context-wide rather than for the popup's origin: an
    // origin-scoped grant is refused for chrome-extension: URLs, which
    // both Playwright and Chrome treat as opaque here.
    await env.ctx.grantPermissions(["clipboard-read", "clipboard-write"]);

    await leaveTransactionDetail(env.page);
    const row = env.page
        .locator("#tx-list .tx-row")
        .filter({ hasText: STUB_TOKEN.symbol });
    await row.waitFor({ state: "visible", timeout: 30000 });
    await row.click();
    await visible(env.page, "#view-transaction");
    await visible(env.page, "#tx-detail-token-contract-section");

    // Seed a sentinel first, so a clipboard that nothing writes to cannot
    // pass on whatever was left in it.
    const SENTINEL = "e2e-clipboard-untouched";
    await env.page.evaluate((s) => navigator.clipboard.writeText(s), SENTINEL);
    const seeded = await env.page.evaluate(() =>
        navigator.clipboard.readText(),
    );
    assert(
        seeded === SENTINEL,
        "the harness could not seed the clipboard, so the assertion below " +
            "would prove nothing; it read back " +
            JSON.stringify(seeded),
    );

    await env.page.locator("#tx-detail-token-contract [data-copy]").click();

    const copied = await env.page.evaluate(() =>
        navigator.clipboard.readText(),
    );
    assert(
        copied.toLowerCase() === STUB_TOKEN.address,
        "tapping the token contract address put " +
            JSON.stringify(copied) +
            " on the clipboard, expected " +
            STUB_TOKEN.address,
    );

    const flash = await env.page.locator("#flash-msg").innerText();
    assert(
        flash.trim() === "Copied!",
        "the copy gave no confirmation, flash line reads " +
            JSON.stringify(flash),
    );
});

// -------------------------------------------- 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",
    );
});

// ------------------------------------------------- Settings screen (#229)

// Settings holds the densest run of $("...") lookups in the codebase, and
// until this section nothing drove it in a browser. One wrong id makes
// settings.init() throw, which aborts the rest of index.js init() before it
// renders anything at all — so a broken id does not degrade Settings, it
// leaves the whole popup blank. These tests assert the controls are there
// AND that they work, because "the view is visible" would still pass
// against a screen whose handlers were never wired.

// The four Token Spam Protection checkboxes, in markup order, with the
// src/shared/state.js key each one is bound to. All four default true.
const SPAM_FILTER_CHECKBOXES = [
    { id: "settings-hide-spoofed-symbols", key: "hideSpoofedSymbols" },
    { id: "settings-hide-low-holders", key: "hideLowHolderTokens" },
    { id: "settings-hide-fraud-contracts", key: "hideFraudContracts" },
    { id: "settings-hide-dust", key: "hideDustTransactions" },
];

// The one toggled through a reopen. Chosen because nothing later in this
// suite depends on it: the other three filter token and transaction lists
// that the ConfirmTx and dApp sections go on to drive.
const TOGGLED_FILTER = "settings-hide-dust";

// Everything the Settings assertions below must observe, recorded as each
// group of them completes. The final test demands the exact set.
//
// The point is that a green run cannot mean the assertions were skipped.
// Navigation that silently fails already fails a test — visible() throws
// on a timeout — but an early return, a deleted test, or a body that
// stopped being reached would otherwise shrink this section quietly
// instead of reddening the run.
const SETTINGS_COVERAGE = [
    "about-well",
    "spam-checkbox-defaults",
    "theme-select",
    "network-select",
    "selector-round-trip",
    "selector-restore",
    "toggle-off-survives-reopen",
    "toggle-on-survives-reopen",
    "wallet-list",
];

// A control read as the DOM has it, not as a selector claims: tag name and
// type distinguish a real <input type="checkbox"> from a <div> that merely
// carries the id, and `checked` is the live property rather than the
// attribute, so it reflects what init() assigned.
function controlState(page, id) {
    return page.evaluate((elementId) => {
        const el = document.getElementById(elementId);
        if (!el) return null;
        return {
            tag: el.tagName.toLowerCase(),
            type: el.type || "",
            checked: el.checked,
            value: el.value,
            options: Array.from(el.options || []).map((o) => o.value),
        };
    }, id);
}

async function checkboxStates(page) {
    const out = {};
    for (const { id } of SPAM_FILTER_CHECKBOXES) {
        out[id] = await controlState(page, id);
    }
    return out;
}

function assertSpamCheckbox(st, id, expected, where) {
    assert(st !== null, "no element with id " + id + " on Settings " + where);
    assert(
        st.tag === "input" && st.type === "checkbox",
        id + " is a <" + st.tag + " type=" + st.type + ">, not a checkbox",
    );
    assert(
        st.checked === expected,
        id +
            " reads " +
            st.checked +
            " " +
            where +
            ", expected " +
            expected +
            " — the checkbox is on screen but not carrying the persisted value",
    );
}

test("Settings renders with the whole screen populated (#229)", async (env) => {
    await visible(env.page, "#view-main");
    await openSettings(env.page);

    // show() writes the About well near its end — only the debug well and
    // the debug-mode checkbox follow it — and showView() is the last thing
    // of all, so an id show() cannot find aborts before Settings is ever
    // displayed. Reading these values back proves show() ran through to
    // there, not just far enough to unhide the section. They are filled
    // from build-time constants that always have a value, so empty means
    // the write did not happen.
    const about = await env.page.evaluate(() => {
        const out = {};
        for (const id of [
            "about-license",
            "about-author",
            "about-version",
            "about-release-date",
            "about-commit-link",
        ]) {
            const el = document.getElementById(id);
            out[id] = el === null ? null : el.textContent.trim();
        }
        return out;
    });
    for (const [id, text] of Object.entries(about)) {
        assert(
            text !== null && text.length > 0,
            "the About well left #" +
                id +
                " unwritten: " +
                JSON.stringify(about),
        );
    }
    env.settingsCoverage.add("about-well");

    // The wallet list is rendered by settings.js rather than authored in
    // index.html, so an empty container means renderWalletListSettings()
    // did not run even though the screen came up.
    const wallets = await env.page
        .locator("#settings-wallet-list .settings-wallet-name")
        .count();
    assert(
        wallets >= 2,
        "Settings lists " +
            wallets +
            " wallets; the suite created two by this point",
    );
    env.settingsCoverage.add("wallet-list");
});

test("the four Token Spam Protection checkboxes render, defaulted on (#229)", async (env) => {
    await openSettings(env.page);
    const states = await checkboxStates(env.page);
    for (const { id } of SPAM_FILTER_CHECKBOXES) {
        assertSpamCheckbox(states[id], id, true, "on first render");
    }
    env.settingsCoverage.add("spam-checkbox-defaults");
});

test("the theme and network selectors render their real choices (#229)", async (env) => {
    await openSettings(env.page);

    const theme = await controlState(env.page, "settings-theme");
    assert(theme !== null, "no #settings-theme element on Settings");
    assert(
        theme.tag === "select",
        "#settings-theme is a <" + theme.tag + ">, not a <select>",
    );
    assert(
        theme.options.join(",") === "system,light,dark",
        "the theme selector offers " + JSON.stringify(theme.options),
    );
    env.settingsCoverage.add("theme-select");

    const network = await controlState(env.page, "settings-network");
    assert(network !== null, "no #settings-network element on Settings");
    assert(
        network.tag === "select",
        "#settings-network is a <" + network.tag + ">, not a <select>",
    );
    const wantNetworks = Object.keys(NETWORKS).sort().join(",");
    assert(
        network.options.slice().sort().join(",") === wantNetworks,
        "the network selector offers " +
            JSON.stringify(network.options) +
            ", expected the networks in src/shared/networks.js: " +
            wantNetworks,
    );
    env.settingsCoverage.add("network-select");
});

// Reads both selectors in one page task, so a round trip cannot observe
// them at two different moments.
async function selectorValues(page) {
    const theme = await controlState(page, "settings-theme");
    const network = await controlState(page, "settings-network");
    assert(theme !== null, "no #settings-theme element on Settings");
    assert(network !== null, "no #settings-network element on Settings");
    return { theme: theme.value, network: network.value };
}

function assertSelectors(got, wantTheme, wantNetwork, where) {
    assert(
        got.theme === wantTheme,
        "the theme selector shows " +
            JSON.stringify(got.theme) +
            " " +
            where +
            ", expected " +
            JSON.stringify(wantTheme),
    );
    assert(
        got.network === wantNetwork,
        "the network selector shows " +
            JSON.stringify(got.network) +
            " " +
            where +
            ", expected " +
            JSON.stringify(wantNetwork),
    );
}

// The two values the selectors are driven to. NEITHER is the first
// <option> of its <select> (`system` and `mainnet` are), and that is the
// entire point: the first option is what the DOM reports with no
// JavaScript involved at all, so asserting it would pass just as happily
// against a Settings screen that never assigned anything. Only a value
// that went out through the change handler and saveState(), and came back
// through loadState() and the assignment show()/init() makes, can be read
// here.
const NONDEFAULT_THEME = "dark";
const NONDEFAULT_NETWORK = "sepolia";

test("the theme and network selectors carry a non-default persisted value (#229)", async (env) => {
    await openSettings(env.page);

    // selectOption() fires "change", which is what the handlers bind.
    await env.page.selectOption("#settings-theme", NONDEFAULT_THEME);
    await env.page.selectOption("#settings-network", NONDEFAULT_NETWORK);

    await reopenPopup(env, "#view-settings");

    assertSelectors(
        await selectorValues(env.page),
        NONDEFAULT_THEME,
        NONDEFAULT_NETWORK,
        "after reopening the popup",
    );
    env.settingsCoverage.add("selector-round-trip");

    // Restore, the same way round, and assert the restore actually took
    // rather than trusting it: the later sections inherit this fixture,
    // and a selector stuck on `dark`/`sepolia` would otherwise be
    // indistinguishable here from one that persists correctly. Switching
    // the network back also returns state.rpcUrl and state.blockscoutUrl
    // to the mainnet endpoints onChainSwitch() remembered, which this
    // fixture never customised and so are the mainnet defaults
    // src/shared/state.js starts with.
    await env.page.selectOption("#settings-theme", "system");
    await env.page.selectOption("#settings-network", "mainnet");

    await reopenPopup(env, "#view-settings");

    assertSelectors(
        await selectorValues(env.page),
        "system",
        "mainnet",
        "after restoring and reopening the popup",
    );
    env.settingsCoverage.add("selector-restore");
});

// The functional half. A checkbox that renders but is not wired looks
// identical on screen; only a value that survives being written to storage
// and read back by a fresh page load tells the two apart. That round trip
// runs through the change handler, saveState(), loadState() and the
// assignment init() makes — every part of the wiring at once.
test("a spam filter toggled in Settings survives a popup reopen (#229)", async (env) => {
    await openSettings(env.page);
    await env.page.click("#" + TOGGLED_FILTER);

    const immediately = await controlState(env.page, TOGGLED_FILTER);
    assert(
        immediately.checked === false,
        "clicking #" + TOGGLED_FILTER + " did not clear it",
    );

    await reopenPopup(env, "#view-settings");

    const after = await checkboxStates(env.page);
    for (const { id } of SPAM_FILTER_CHECKBOXES) {
        assertSpamCheckbox(
            after[id],
            id,
            id !== TOGGLED_FILTER,
            "after reopening the popup",
        );
    }
    env.settingsCoverage.add("toggle-off-survives-reopen");
});

test("turning the same filter back on survives a reopen too (#229)", async (env) => {
    await openSettings(env.page);
    await env.page.click("#" + TOGGLED_FILTER);

    await reopenPopup(env, "#view-settings");

    // Restores the fixture the later sections inherit, and rules out a
    // checkbox that persists "off" only because it is stuck there.
    const after = await checkboxStates(env.page);
    for (const { id } of SPAM_FILTER_CHECKBOXES) {
        assertSpamCheckbox(after[id], id, true, "after toggling back on");
    }
    env.settingsCoverage.add("toggle-on-survives-reopen");

    await env.page.click("#btn-settings-back");
    await visible(env.page, "#view-main");
});

test("the Settings assertions above all ran (#229)", async (env) => {
    const seen = [...env.settingsCoverage].sort();
    const want = SETTINGS_COVERAGE.slice().sort();
    assert(
        seen.join(",") === want.join(","),
        "the Settings section covered " +
            JSON.stringify(seen) +
            " but must cover " +
            JSON.stringify(want) +
            " — a green run here would otherwise mean only that fewer " +
            "assertions ran, not that they passed",
    );
});

// ------------------------------------------------ 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",
    );
});

// ------------------------- the popup's own send, end to end (#305)
//
// Everything above this point stops at the confirmation screen. Nothing in
// the suite had ever clicked #btn-confirm-send, so the wallet's own Send ->
// ConfirmTx -> Sign & Send -> WaitTx path had no coverage at all, and issue
// #305 shipped through the gap: the screen was rendered from the explorer's
// decimals while the transfer was encoded from decimals() read off the
// contract at signing time, with nothing comparing the two.
//
// These two tests drive that path to a broadcast and read the amount out of
// the bytes the node was handed. The first asserts those bytes against what
// the screen displayed; the second makes the contract answer a different
// scale after the screen was built, and requires that nothing is broadcast.

// keccak("transfer(address,uint256)")[0:4].
const SELECTOR_TRANSFER = "0xa9059cbb";

// What decimals() starts answering once the confirmation screen has been
// built. The explorer reports 6 for the same token, so a wallet that encodes
// from the contract signs 10^12 times the amount it displayed.
const LYING_DECIMALS = "18";

const TOKEN_DECIMALS = Number(STUB_TOKEN.decimals);

// The transfer() call inside a raw signed transaction, hand-decoded.
//
// Deliberately not run through an ethers Interface built from the
// extension's own ABI: what is under assertion is the bytes that reached the
// node, and the fewer assumptions the wallet and the assertion share, the
// less room there is for both to be wrong in the same direction.
function decodeTransfer(rawSignedTx) {
    const signed = Transaction.from(rawSignedTx);
    const data = signed.data.toLowerCase();
    assert(
        data.startsWith(SELECTOR_TRANSFER) && data.length === 10 + 128,
        "the broadcast transaction is not an ERC-20 transfer() call: " + data,
    );
    return {
        signed,
        recipient: getAddress("0x" + data.slice(34, 74)),
        rawAmount: BigInt("0x" + data.slice(74)),
    };
}

// The amount the confirmation screen is showing, verbatim.
async function shownAmount(page) {
    return (await page.locator("#confirm-amount").innerText()).trim();
}

async function fillPasswordAndSend(page) {
    await page.fill("#confirm-tx-password", PASSWORD);
    await page.click("#btn-confirm-send");
}

async function goToTokenConfirm(env) {
    await goToConfirm(env.page, {
        token: STUB_TOKEN.address,
        balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
        amount: TOKEN_AMOUNT,
    });
    await waitForEstimate(env.page);
    const shown = await shownAmount(env.page);
    assert(
        shown === TOKEN_AMOUNT + " " + STUB_TOKEN.symbol,
        "the confirmation screen is not showing the amount that was entered: " +
            JSON.stringify(shown),
    );
    return shown;
}

test("the popup's own ERC-20 send broadcasts the amount it displayed (#305)", async (env) => {
    // The previous test left the ETH balance at the fee-only fixture, which
    // blocks sending outright; this one has to be able to press Send.
    env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
    await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
    const shown = await goToTokenConfirm(env);

    const before = env.routeOpts.broadcastTransactions.length;
    // Confirm the transaction once it is broadcast, so the wait screen
    // resolves to the success view instead of polling for the rest of the run.
    env.routeOpts.seedReceipt = true;
    await fillPasswordAndSend(env.page);
    await visible(env.page, "#view-wait-tx", 60000);

    const broadcast = env.routeOpts.broadcastTransactions;
    assert(
        broadcast.length === before + 1,
        "expected exactly one raw transaction to reach the RPC, got " +
            (broadcast.length - before),
    );
    const { signed, recipient, rawAmount } = decodeTransfer(
        broadcast[broadcast.length - 1],
    );

    // The measurement, printed on every run: the amount the user read, and
    // what the signed bytes mean at each of the two candidate scales. Under
    // the defect these three lines disagree.
    console.log(
        "# erc-20 send artifact: displayed=" +
            JSON.stringify(shown) +
            " rawAmount=" +
            rawAmount +
            " asIf" +
            TOKEN_DECIMALS +
            "Decimals=" +
            formatUnits(rawAmount, TOKEN_DECIMALS) +
            " asIf" +
            LYING_DECIMALS +
            "Decimals=" +
            formatUnits(rawAmount, Number(LYING_DECIMALS)),
    );

    assert(
        getAddress(signed.to) === getAddress(STUB_TOKEN.address),
        "the broadcast transaction does not call the token contract: " +
            signed.to,
    );
    assert(
        recipient === getAddress(STUB_COUNTERPARTY),
        "the broadcast transfer goes to " + recipient,
    );
    // What the whole issue turns on: the signed amount, read back at the
    // scale the SCREEN rendered with, is the number the screen rendered.
    const wanted = parseUnits(shown.split(" ")[0], TOKEN_DECIMALS);
    assert(
        rawAmount === wanted,
        "the broadcast transfer moves " +
            rawAmount +
            " base units, which is " +
            formatUnits(rawAmount, TOKEN_DECIMALS) +
            " " +
            STUB_TOKEN.symbol +
            " at the scale the confirmation screen displayed — but the screen" +
            " displayed " +
            JSON.stringify(shown) +
            ", i.e. " +
            wanted +
            " base units (#305)",
    );

    const summary = (
        await env.page.locator("#wait-tx-summary").innerText()
    ).trim();
    assert(
        summary === shown,
        "the wait screen summarises the send as " +
            JSON.stringify(summary) +
            ", not as the approved " +
            JSON.stringify(shown),
    );

    await visible(env.page, "#view-success-tx", 60000);
    await env.page.click("#btn-success-tx-done");
    await visible(env.page, "#view-address");
    env.routeOpts.seedReceipt = false;
});

test("a token that lies about decimals() at signing time broadcasts nothing (#305)", async (env) => {
    const shown = await goToTokenConfirm(env);

    // Only now, with the screen already built and its estimate already taken
    // at the explorer's scale, does the contract start answering differently.
    // This is the whole shape of the defect: a value read at signing time that
    // nothing on screen was ever derived from.
    env.routeOpts.tokenDecimalsOverride = LYING_DECIMALS;
    const before = env.routeOpts.broadcastTransactions.length;
    await fillPasswordAndSend(env.page);
    await visible(env.page, "#view-error-tx", 60000);
    env.routeOpts.tokenDecimalsOverride = null;

    assert(
        env.routeOpts.broadcastTransactions.length === before,
        "a transfer encoded against a contract that contradicts the " +
            "confirmation screen still reached the RPC (#305)",
    );

    const message = (
        await env.page.locator("#error-tx-message").innerText()
    ).trim();
    console.log(
        "# erc-20 decimals refusal: displayed=" +
            JSON.stringify(shown) +
            " contract=" +
            LYING_DECIMALS +
            " message=" +
            JSON.stringify(message),
    );
    assert(
        message.includes("reports " + LYING_DECIMALS + " decimal places") &&
            message.includes("displayed using " + STUB_TOKEN.decimals),
        "the refusal does not name both scales it is refusing over: " +
            JSON.stringify(message),
    );
    assert(
        /^[A-Z].*\.$/s.test(message),
        "the refusal is not a full sentence: " + JSON.stringify(message),
    );

    await env.page.click("#btn-error-tx-done");
    await visible(env.page, "#view-address");
});

// ------------------------------------------- hostile token symbol (#307)
//
// The reproduction from the issue, in the real browser against the real
// shipped manifest. A token symbol is whatever the contract's symbol()
// returns, the explorer passes it through, and the popup interpolated it
// into an innerHTML string — so a token with 1,000 holders airdropped to
// the victim could paint a full-viewport cross-origin iframe over the
// wallet's own UI, on the screens where the user types their password.
//
// The iframe count and the rendered text are asserted separately on
// purpose, and neither substitutes for the other. `frame-src 'none'` stops
// an injected frame LOADING; it does not stop the element existing, so a
// zero iframe count is a claim about the escaping and about nothing else.
// The literal capped text is the claim that the symbol was treated as a
// string all the way down.
//
// The iframe count is taken on the address screen before anything is
// clicked. That is where the injected frame lands first, and it covers the
// viewport: with the escaping removed, every later step fails as a click
// timeout ("<iframe id=\"pwn\"> intercepts pointer events") rather than as
// anything that names the defect.

// Verbatim from the issue's reproduction.
const HOSTILE_SYMBOL =
    '<iframe id="pwn" src="https://dapp.e2e.test/" ' +
    'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';

// What a correctly escaped and capped render of it reads as: the first
// MAX_SYMBOL_LENGTH-1 characters and an ellipsis. Spelled out rather than
// imported, so a change to the cap has to be restated here deliberately
// instead of being absorbed by a shared constant.
const HOSTILE_SYMBOL_DISPLAYED = "<iframe id=" + "…";

// Everything the popup can say about an injected symbol, read out of the
// live DOM in one pass.
function hostileSymbolState(page, tokenAddress) {
    return page.evaluate((addr) => {
        const row = document.querySelector(
            '#wallet-list [data-token="' + addr + '"]',
        );
        // balanceLine() emits <div data-token><span><span>SYMBOL</span>…
        // so this is the span the symbol itself was written into.
        const symbolEl = row && row.firstElementChild.firstElementChild;
        return {
            rowFound: !!row,
            rowText: row ? row.innerText.trim() : "",
            symbolText: symbolEl ? symbolEl.textContent : "",
            // The symbol's own span must hold text and nothing else. An
            // element child here is the injection, whether or not it
            // happens to be an iframe.
            symbolElementChildren: symbolEl
                ? symbolEl.querySelectorAll("*").length
                : -1,
            // The whole popup document, not just the row: an injected
            // element positioned fixed can be anywhere in the tree.
            iframes: document.querySelectorAll("iframe").length,
            pwnPresent: !!document.getElementById("pwn"),
        };
    }, tokenAddress);
}

test("a token whose symbol() returns markup renders as text (#307)", async (env) => {
    env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
    env.routeOpts.seedTokenBalance = true;
    env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL;
    console.log(
        "# stub token symbol() now returns: " + JSON.stringify(HOSTILE_SYMBOL),
    );

    // Close and reopen so the refresh that runs on open fetches balances
    // with the hostile symbol in them.
    await reopenPopup(env, "#view-address");
    await env.page.waitForFunction(
        (addr) =>
            !!document.querySelector(
                '#address-balances [data-token="' + addr + '"]',
            ),
        STUB_TOKEN.address,
        { timeout: 60000 },
    );
    const onAddress = await env.page.evaluate(() => ({
        iframes: document.querySelectorAll("iframe").length,
        pwnPresent: !!document.getElementById("pwn"),
    }));
    console.log("# address-detail iframes = " + onAddress.iframes);
    assert(
        onAddress.iframes === 0 && !onAddress.pwnPresent,
        "the address screen contains " +
            onAddress.iframes +
            " iframe(s) after a hostile symbol rendered (#307)",
    );

    await env.page.click("#btn-address-back");
    await visible(env.page, "#view-main");
    await visible(
        env.page,
        '#wallet-list [data-token="' + STUB_TOKEN.address + '"]',
        60000,
    );

    const st = await hostileSymbolState(env.page, STUB_TOKEN.address);
    console.log(
        "# iframes in the popup DOM = " +
            st.iframes +
            " | #pwn present = " +
            st.pwnPresent +
            " | symbol = " +
            JSON.stringify(st.symbolText),
    );

    assert(st.rowFound, "the hostile token never rendered a row at all");
    assert(
        st.iframes === 0,
        "the popup DOM contains " + st.iframes + " iframe(s) (#307)",
    );
    assert(!st.pwnPresent, "the injected #pwn element is in the popup DOM");
    assert(
        st.symbolElementChildren === 0,
        "the symbol span grew " +
            st.symbolElementChildren +
            " element children out of a token symbol (#307)",
    );
    assert(
        st.symbolText === HOSTILE_SYMBOL_DISPLAYED,
        "the symbol did not render as the literal capped text " +
            JSON.stringify(HOSTILE_SYMBOL_DISPLAYED) +
            ": " +
            JSON.stringify(st.symbolText),
    );
    assert(
        !st.rowText.includes("z-index"),
        "the uncapped symbol reached the screen: " + JSON.stringify(st.rowText),
    );

    // Put the fixture back before the next test reads it, and let the
    // stored balances be rewritten with the honest symbol.
    env.routeOpts.tokenSymbolOverride = null;
    await reopenPopup(env, "#view-main");
    await env.page.waitForFunction(
        (addr) => {
            const row = document.querySelector(
                '#wallet-list [data-token="' + addr + '"]',
            );
            return !!row && row.innerText.includes("E2E");
        },
        STUB_TOKEN.address,
        { timeout: 60000 },
    );
});

// ------------------------------------------- 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, url = DAPP_URL) {
    const page = await ctx.newPage();
    await page.goto(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.
//
// What the swallow costs is not the same for every button, so neither is what
// proves the click landed:
//
//   #btn-reject-sign, #btn-reject-tx — their disconnect leaves the approval
//     pending, so a click that never landed leaves the dApp promise unsettled
//     and the assertion after the call fails on its own.
//   #btn-approve — only a decision resolves the promise, and a swallowed click
//     cannot produce settled === "resolved".
//   #btn-reject on the site prompt — NOT self-proving. A page that went away
//     without the click landing disconnects the approval port, the background
//     settles that as 4001, and 4001 is exactly what assertUserRejection
//     accepts. That call site arms the click trace below and asserts it.
//
// 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;
    }
}

// Evidence that a click reached the button, for the button whose outcome
// cannot tell.
//
// A capture-phase listener on the document runs ahead of the button's own
// handler and writes one key with localStorage.setItem(), which is synchronous
// and therefore already in the browser process when the handler tears the page
// down a line later. Any other page of the extension origin can read it back,
// and env.page is one. The listener only observes: nothing about the shipped
// decide-then-close is deferred, patched or reordered.
const CLICK_TRACE_KEY = "autistmask-e2e-click-landed";

async function armClickTrace(env, page, selector) {
    await env.page.evaluate(
        (key) => localStorage.removeItem(key),
        CLICK_TRACE_KEY,
    );
    await page.evaluate(
        ({ key, sel }) => {
            document.addEventListener(
                "click",
                (e) => {
                    const target = e.target;
                    if (target && target.closest && target.closest(sel)) {
                        localStorage.setItem(key, sel);
                    }
                },
                true,
            );
        },
        { key: CLICK_TRACE_KEY, sel: selector },
    );
}

// The write crosses processes to reach env.page's renderer, so it is waited
// for rather than read once. Nothing else in the test is timed on this.
async function assertClickLanded(env, selector, timeout = 5000) {
    const deadline = Date.now() + timeout;
    let seen;
    for (;;) {
        seen = await env.page.evaluate(
            (key) => localStorage.getItem(key),
            CLICK_TRACE_KEY,
        );
        if (seen === selector || Date.now() > deadline) break;
        await sleep(25);
    }
    assert(
        seen === selector,
        "the click on " +
            selector +
            " never reached the button, so the outcome below proves nothing " +
            "about it: trace was " +
            JSON.stringify(seen),
    );
}

// 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),
        );

        // The control for the phishing test below: this origin is not on the
        // blocklist, so the banner must be absent here. Without it a banner
        // that was simply always visible would satisfy that test.
        assert(
            await popup.locator("#approve-site-phishing-warning").isHidden(),
            "the phishing warning is showing for an origin that is not on " +
                "the blocklist, so its appearance proves nothing",
        );

        // 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");
        // The rejection this asserts is also what an unclicked prompt that
        // simply went away produces, so the click itself is witnessed.
        await armClickTrace(env, popup, "#btn-reject");
        await clickAndClose(popup, "#btn-reject");
        await assertClickLanded(env, "#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("a connect request from a blocklisted site is flagged (#219)", async (env) => {
    // The vendored blocklist, end to end: a real entry from the shipped
    // artifact, served as a real http(s) origin, reaching the real background
    // check and the real approval screen. Nothing about the list is stubbed —
    // there is nothing left to stub, since the extension no longer fetches it.
    const phishingDapp = await openDapp(env.ctx, PHISHING_DAPP_URL);
    const hostname = new URL(PHISHING_DAPP_URL).hostname;
    try {
        await reserveApprovalTab(env);
        await startRequest(
            phishingDapp,
            "phishing-accounts",
            "eth_requestAccounts",
            [],
        );
        const popup = await openSiteApprovalPopup(env);
        try {
            await visible(popup, "#view-approve-site");

            const shown = await popup.locator("#approve-hostname").innerText();
            assert(
                shown === hostname,
                "the site prompt names the wrong origin: " +
                    JSON.stringify(shown),
            );

            await visible(popup, "#approve-site-phishing-warning");
            console.log("# phishing warning shown for " + hostname);

            // Not remembered: a remembered decision for this origin would
            // outlive the test.
            await popup.uncheck("#approve-remember");
            await popup.click("#btn-reject");

            await assertUserRejection(
                phishingDapp,
                "phishing-accounts",
                "the blocklisted site's eth_requestAccounts",
            );
        } finally {
            await closeApprovalPages(env.ctx);
        }
    } finally {
        await phishingDapp.close();
    }
});

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,
        seedNativeTransfer: false,
        seedTokenBalance: false,
        ethBalanceWei: null,
        failGasEstimate: false,
        holdGasEstimate: false,
        // What decimals() answers for the stub token, when it is to answer
        // something other than the value the same fixture reports through
        // Blockscout. The token that lies about its scale (#305).
        tokenDecimalsOverride: null,
        // What the explorer reports as the stub token's symbol. The token
        // whose symbol() returns markup (#307).
        tokenSymbolOverride: null,
        // Whether eth_getTransactionReceipt confirms a transaction rather than
        // answering "not mined yet".
        seedReceipt: 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,
        // What the Settings section (#229) actually observed. A guard test
        // at the end of that section demands the full set, so a skipped or
        // silently shortened assertion reddens the run instead of shrinking
        // it.
        settingsCoverage: new Set(),
        // 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;
});
