// 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 { formatEther } = require("ethers");
const {
    PASSWORD,
    createWallet,
    launch,
    openAddressDetail,
    openPopup,
    pageCompilesWasm,
    visible,
} = require("./harness");
const {
    FEE_ESTIMATE_WEI,
    FEE_RESERVE_WEI,
    STUB_COUNTERPARTY,
    STUB_TOKEN,
    STUB_TX_HASH,
} = require("./network");
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");

const TEST_TIMEOUT_MS = 120000;

// How long to keep collecting after the final test returns; see the
// trailing drain in main().
const TRAILING_WATCH_MS = 1500;

const tests = [];

function test(name, fn) {
    tests.push({ name, fn });
}

function assert(cond, message) {
    if (!cond) throw new Error(message);
}

function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

function withTimeout(promise, name) {
    let timer;
    const timeout = new Promise((_, reject) => {
        timer = setTimeout(
            () =>
                reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")),
            TEST_TIMEOUT_MS,
        );
    });
    return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

// ----------------------------------------------------------------- tests

test("popup loads and reaches the welcome view", async (env) => {
    env.page = await openPopup(env.ctx, env.popupUrl);
    await visible(env.page, "#view-welcome");
    const title = await env.page.title();
    assert(title === "AutistMask", "unexpected popup title: " + title);
});

// The empirical half of #182. The manifest change is only a claim about
// what the CSP permits; this is the observation. Two things have to hold
// together, and the run covers both: the popup realm compiles WASM (here),
// and no WASM refusal or abort is recorded anywhere in the run — the
// harness allowlist that used to excuse exactly that error is now empty,
// so a recurrence fails whichever test it lands in rather than being
// tolerated. Since libsodium's WASM module is embedded in the bundle and
// needs no fetch, a realm that compiles WASM is a realm where libsodium
// takes the WASM path, and the next test drives a real vault encryption
// through it.
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
    const ok = await pageCompilesWasm(env.page);
    assert(
        ok,
        "the popup refused to compile WebAssembly. The shipped manifest CSP " +
            "has lost 'wasm-unsafe-eval', so libsodium is back on its wasm2js " +
            "fallback and every password derivation costs roughly 20x what it " +
            "should — see the backend note in src/shared/vault.js",
    );
});

test("wallet creation through the UI reaches the main view", async (env) => {
    env.phrase = await createWallet(env.page);
    assert(
        env.phrase.split(/\s+/).length >= 12,
        "wallet creation did not yield a recovery phrase",
    );
    const addrCount = await env.page
        .locator("#wallet-list .btn-addr-info")
        .count();
    assert(addrCount > 0, "no addresses rendered in the wallet list");
});

test("add token screen opens from address detail (#150)", async (env) => {
    await openAddressDetail(env.page);
    await env.page.click("#btn-add-token");
    await visible(env.page, "#view-add-token");
    const quickPicks = await env.page
        .locator("#common-token-list .common-token")
        .count();
    assert(quickPicks > 0, "no common-token quick-pick buttons rendered");
});

test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
    // Serve the stubbed token transfer from here on, then reload so the
    // address detail screen refetches its transaction list.
    env.routeOpts.seedTokenTransfer = true;
    await env.page.reload();
    await openAddressDetail(env.page);

    await visible(env.page, "#tx-list .tx-row");
    const rowText = await env.page
        .locator("#tx-list .tx-row")
        .first()
        .innerText();
    assert(
        rowText.includes(STUB_TOKEN.symbol),
        "token transfer row missing symbol " +
            STUB_TOKEN.symbol +
            ", got: " +
            JSON.stringify(rowText),
    );

    await env.page.locator("#tx-list .tx-row").first().click();
    await visible(env.page, "#view-transaction");

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

    // The token contract row is the field that crashes when
    // addressDotHtml is not imported: it renders only for transfers with
    // a contractAddress, which is every ERC-20 transfer.
    await visible(env.page, "#tx-detail-token-contract-section");
    const contract = env.page.locator("#tx-detail-token-contract");
    const contractText = await contract.innerText();
    assert(
        contractText.toLowerCase().includes(STUB_TOKEN.address),
        "token contract row missing the contract address, got: " +
            JSON.stringify(contractText),
    );
    const dots = await contract.locator('span[style*="border-radius"]').count();
    assert(dots > 0, "token contract row rendered without its colour dot");
});

// -------------------------------------------- recovery phrase (#161)

// The gear toggles, so pressing it while Settings is already up leaves it.
async function openSettings(page) {
    if (!(await page.isVisible("#view-settings"))) {
        await page.click("#btn-settings");
    }
    await visible(page, "#view-settings");
}

// Everything the recovery phrase screen is holding, read straight out of
// the DOM whether or not that screen is the one on top. Reading it while it
// is hidden is the point: "cleared on leave" means the node is empty, not
// merely off-screen.
async function phraseScreenState(page) {
    return page.evaluate(() => ({
        value: document.getElementById("show-phrase-value").textContent,
        error: document.getElementById("show-phrase-flash").textContent,
        html: document.getElementById("view-show-phrase").innerHTML,
        resultHidden: document
            .getElementById("show-phrase-result")
            .classList.contains("hidden"),
        viewHidden: document
            .getElementById("view-show-phrase")
            .classList.contains("hidden"),
    }));
}

async function openPhraseScreen(page) {
    await openSettings(page);
    await page.click("#settings-wallet-list .btn-show-phrase");
    await visible(page, "#view-show-phrase");
}

async function revealPhrase(page) {
    await page.fill("#show-phrase-password", PASSWORD);
    await page.click("#btn-show-phrase-reveal");
    await visible(page, "#show-phrase-result", 60000);
}

function assertWiped(st, phrase, where) {
    assert(st.value === "", "phrase still in the DOM " + where);
    assert(st.resultHidden, "result section still shown " + where);
    assert(
        !st.html.includes(phrase),
        "the recovery phrase is still somewhere in the screen markup " + where,
    );
}

test("only an HD wallet is offered the recovery phrase action (#161)", async (env) => {
    await openSettings(env.page);
    const offered = await env.page
        .locator("#settings-wallet-list .btn-show-phrase")
        .count();
    const wallets = await env.page
        .locator("#settings-wallet-list .btn-delete-wallet")
        .count();
    assert(wallets === 1, "expected exactly one wallet row, got " + wallets);
    assert(
        offered === 1,
        "the HD wallet was not offered the recovery phrase action",
    );
});

// The other half of the gate, against the real UI: a wallet holding a bare
// private key has no phrase to show, so no row of it may offer the action.
// The key is generated here rather than committed — the repo holds no
// private keys, test ones included.
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
    const { Wallet } = require("ethers");

    await openSettings(env.page);
    await env.page.click("#btn-main-add-wallet");
    await visible(env.page, "#view-add-wallet");
    await env.page.click("#tab-privkey");
    await env.page.fill(
        "#import-private-key",
        Wallet.createRandom().privateKey,
    );
    await env.page.fill("#add-wallet-password", PASSWORD);
    await env.page.fill("#add-wallet-password-confirm", PASSWORD);
    await env.page.click("#btn-add-wallet-confirm");
    await visible(env.page, "#view-main", 60000);

    await openSettings(env.page);
    const wallets = await env.page
        .locator("#settings-wallet-list .btn-delete-wallet")
        .count();
    const offered = await env.page
        .locator("#settings-wallet-list .btn-show-phrase")
        .count();
    assert(wallets === 2, "expected two wallet rows, got " + wallets);
    assert(
        offered === 1,
        "the key wallet was offered the recovery phrase action",
    );
});

test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
    await openPhraseScreen(env.page);
    const st = await phraseScreenState(env.page);
    assertWiped(st, env.phrase, "before any password was entered");
    const passwordShown = await env.page.isVisible(
        "#show-phrase-password-section",
    );
    assert(passwordShown, "the password prompt is not shown");
});

test("a wrong password reveals nothing (#161)", async (env) => {
    await env.page.fill("#show-phrase-password", "not-the-password");
    await env.page.click("#btn-show-phrase-reveal");
    await env.page.waitForFunction(
        () =>
            document.getElementById("show-phrase-flash").textContent.length > 0,
        null,
        { timeout: 60000 },
    );

    const st = await phraseScreenState(env.page);
    assertWiped(st, env.phrase, "after a wrong password");
    assert(
        /^[A-Z].*\.$/.test(st.error.trim()),
        "the wrong-password error is not a full sentence: " +
            JSON.stringify(st.error),
    );
});

test("the correct password reveals the full phrase, and nothing logs it (#161)", async (env) => {
    const console_ = [];
    const listener = (msg) => console_.push(msg.text());
    env.page.on("console", listener);
    try {
        await revealPhrase(env.page);

        const st = await phraseScreenState(env.page);
        assert(
            st.value === env.phrase,
            "the displayed phrase is not the wallet's phrase, verbatim",
        );
        const promptShown = await env.page.isVisible(
            "#show-phrase-password-section",
        );
        assert(!promptShown, "the password prompt is still shown after unlock");

        // Full Identifiers Policy: shown whole, and copyable.
        const title = await env.page.getAttribute(
            "#show-phrase-value",
            "title",
        );
        assert(title === "Click to copy", "the phrase is not click-to-copy");

        const leaked = console_.filter((line) => line.includes(env.phrase));
        assert(
            leaked.length === 0,
            "the recovery phrase reached the console: " +
                JSON.stringify(leaked),
        );
    } finally {
        env.page.off("console", listener);
    }
});

test('"Back" wipes the revealed phrase (#161)', async (env) => {
    await env.page.click("#btn-show-phrase-back");
    await visible(env.page, "#view-settings");
    const st = await phraseScreenState(env.page);
    assert(st.viewHidden, "the recovery phrase screen is still on top");
    assertWiped(st, env.phrase, "after Back");
});

// The settings gear leaves the screen without touching its Back button. A
// clear wired only to Back would pass the test above and leak here.
test("leaving by the settings gear wipes it too (#161)", async (env) => {
    await openPhraseScreen(env.page);
    await revealPhrase(env.page);
    await env.page.click("#btn-settings");
    await visible(env.page, "#view-settings");
    const st = await phraseScreenState(env.page);
    assertWiped(st, env.phrase, "after leaving via the settings gear");
});

// The same leave, but taken while the decrypt is still running. Both
// clicks are dispatched inside one page task on purpose: "Reveal" runs its
// handler up to the await, the gear then runs the leave — and the wipe with
// it — to completion, and the decrypt's continuation resumes afterwards.
// Without a liveness check that continuation writes the phrase into the
// hidden screen after the wipe, and nothing is left to wipe it again.
//
// A human cannot produce this interleaving by hand once libsodium's wasm is
// warm, because crypto_pwhash is synchronous and the only suspension point
// is a microtask; the window a user can actually hit is a still-pending
// sodium.ready on the first vault use of a page load. Forcing it here is
// the only way to test the guard deterministically.
test("leaving while the decrypt is in flight reveals nothing (#161)", async (env) => {
    await openPhraseScreen(env.page);
    await env.page.fill("#show-phrase-password", PASSWORD);
    await env.page.evaluate(() => {
        document.getElementById("btn-show-phrase-reveal").click();
        document.getElementById("btn-settings").click();
    });
    await visible(env.page, "#view-settings");

    // The Reveal button is disabled for exactly the duration of the
    // decrypt and re-enabled in the same continuation that would have
    // written the phrase, so waiting for it to come back is a precise
    // "the decrypt has settled and its handler has finished" signal
    // rather than a guess at a duration.
    await env.page.waitForFunction(
        () => !document.getElementById("btn-show-phrase-reveal").disabled,
        null,
        { timeout: 60000 },
    );
    await sleep(2000);

    const st = await phraseScreenState(env.page);
    // Printed on every run, pass or fail: "the phrase is not there" is
    // worth more as a measurement than as a silent assertion, and the
    // same line read from a build without the guard is what this test
    // exists to prevent.
    console.log(
        "# probe: len=" +
            st.value.length +
            " equalsPhrase=" +
            (st.value === env.phrase) +
            " resultHidden=" +
            st.resultHidden +
            " viewHidden=" +
            st.viewHidden,
    );
    assert(st.viewHidden, "the recovery phrase screen is still on top");
    assertWiped(st, env.phrase, "after leaving mid-decrypt");
});

// Closing and reopening the page rather than reloading it: that is what
// the toolbar popup actually does, and the persisted currentView is
// "show-phrase" at the moment it happens, which is precisely the state
// RESTORABLE_VIEWS has to refuse.
test("reopening the popup never lands on the phrase screen (#161)", async (env) => {
    await openPhraseScreen(env.page);
    await revealPhrase(env.page);

    await env.page.close();
    env.page = await openPopup(env.ctx, env.popupUrl);
    await visible(env.page, "#view-main");

    const st = await phraseScreenState(env.page);
    assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
    assertWiped(st, env.phrase, "after reopening the popup");
});

// -------------------------------------------- address removal (#162)

// Number of address rows across every wallet in the list, counted in the DOM
// whether or not Home is the screen on top.
function addressRowCount(page) {
    return page.locator("#wallet-list .btn-addr-info").count();
}

function waitForAddressRows(page, n) {
    return page.waitForFunction(
        (want) =>
            document.querySelectorAll("#wallet-list .btn-addr-info").length ===
            want,
        n,
        { timeout: 60000 },
    );
}

// The suite arrives here with two wallets, an HD one and a key one, holding
// one address each.
test("only a wallet that can spare an address offers to remove one (#162)", async (env) => {
    await visible(env.page, "#view-main");
    const rows = await addressRowCount(env.page);
    assert(rows === 2, "expected two address rows, got " + rows);
    const offered = await env.page
        .locator("#wallet-list .btn-remove-address")
        .count();
    assert(
        offered === 0,
        "a wallet holding its last address offered to remove it",
    );

    await env.page.click("#wallet-list .btn-add-address");
    await waitForAddressRows(env.page, 3);

    // Only the HD wallet's two rows; the key wallet still holds one address.
    const nowOffered = await env.page
        .locator("#wallet-list .btn-remove-address")
        .count();
    assert(
        nowOffered === 2,
        "expected the HD wallet's two rows to offer removal, got " + nowOffered,
    );
});

// The gate itself: the control opens a confirmation, and leaving that
// confirmation by "Back" removes nothing.
test("leaving the removal confirmation removes nothing (#162)", async (env) => {
    await env.page.locator("#wallet-list .btn-remove-address").nth(1).click();
    await visible(env.page, "#view-delete-address-confirm");

    const label = await env.page.locator("#delete-address-label").innerText();
    assert(
        label === "Address 2",
        "the confirmation names the wrong address: " + JSON.stringify(label),
    );

    // The route back is written by the view, not by index.html, so an empty
    // paragraph here means the user is confirming with no idea what it
    // takes to undo. This wallet is an HD one, so it is told about its
    // recovery phrase.
    const recovery = await env.page
        .locator("#delete-address-recovery")
        .innerText();
    assert(
        recovery.includes("delete the whole wallet in Settings") &&
            recovery.includes("recovery phrase"),
        "the confirmation does not state the route back: " +
            JSON.stringify(recovery),
    );

    // "Back" re-renders Home, so a count taken after it is a real
    // measurement of the wallet rather than a stale screen.
    await env.page.click("#btn-delete-address-back");
    await visible(env.page, "#view-main");
    const rows = await addressRowCount(env.page);
    assert(rows === 3, "the address was removed without a confirmation");
});

test("confirming removes the address and returns Home (#162)", async (env) => {
    await env.page.locator("#wallet-list .btn-remove-address").nth(1).click();
    await visible(env.page, "#view-delete-address-confirm");
    await env.page.click("#btn-delete-address-confirm");
    await visible(env.page, "#view-main");

    await waitForAddressRows(env.page, 2);
    const offered = await env.page
        .locator("#wallet-list .btn-remove-address")
        .count();
    assert(
        offered === 0,
        "the HD wallet still offers to remove its last address",
    );
});

// ------------------------------------------------ dust threshold (#233)

// The popup size README documents the UI as designed for. Pages in this
// context otherwise get Playwright's 1280x720 default, at which the flash
// line has room for any plausible message and never wraps — measuring
// there would pass for every string and prove nothing.
const POPUP_VIEWPORT = { width: 360, height: 600 };

// Everything below the flash line that must not move when it fills, plus
// the height of the line itself. Runs in the page.
//
// Positions are in document coordinates, not viewport coordinates:
// tabbing out of the field to fire "change" scrolls the popup, and a
// getBoundingClientRect().top read across that scroll reports a thousand
// pixels of movement that is the scroll, not a layout shift.
function measureFlashLine() {
    const top = (id) =>
        document.getElementById(id).getBoundingClientRect().top +
        window.scrollY;
    return {
        text: document.getElementById("flash-msg").textContent,
        flashHeight: document
            .getElementById("flash-msg")
            .getBoundingClientRect().height,
        settingsTop: top("view-settings"),
        fieldTop: top("settings-dust-threshold"),
    };
}

// Polling one evaluate() rather than waitForFunction() plus a second
// round trip to measure: showFlash() clears the line again after 2s, and
// measuring in a separate call can land after that and read an empty
// line — which would pass however long the message is. Here the text
// check and the geometry come from the same page task, so what is
// measured is always the filled line. Missing the 2s window entirely
// throws; it cannot go green.
async function waitForFilledFlashLine(page) {
    const deadline = Date.now() + 15000;
    for (;;) {
        const m = await page.evaluate(measureFlashLine);
        if (m.text.length > 0) return m;
        if (Date.now() > deadline) {
            throw new Error("the flash line never filled");
        }
        await sleep(25);
    }
}

// README, No Layout Shift: the rejection message goes into #flash-msg,
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs. Reserving
// the space is not enough on its own — a message too long for one line
// wraps and pushes everything below it down anyway, which is what the
// first version of this change shipped: 75 characters, 32px, the settings
// view and the threshold field 12px lower than with an empty line.
//
// So this measures rather than inspects markup. It is the only assertion
// in the repo that can see the wording grow: the unit suite runs on the
// node environment with no layout engine, where every height is zero (see
// the note in tests/dustThreshold.test.js). Lengthen
// DUST_THRESHOLD_MESSAGE past one line and this test goes red.
test("a rejected dust threshold shifts no layout (#233)", async (env) => {
    const page = await openPopup(env.ctx, env.popupUrl);
    try {
        await page.setViewportSize(POPUP_VIEWPORT);
        await openSettings(page);

        const before = await page.evaluate(measureFlashLine);
        assert(
            before.text === "",
            "the flash line was not empty at the baseline measurement: " +
                JSON.stringify(before.text),
        );

        // "change" fires on blur, not on typing, so fill() alone is not
        // enough — it only dispatches "input".
        await page.fill("#settings-dust-threshold", "1.5");
        await page.locator("#settings-dust-threshold").press("Tab");

        const after = await waitForFilledFlashLine(page);

        // Printed pass or fail: the numbers are the evidence, and a
        // silent assertion would leave the reader taking this on trust.
        console.log(
            "# dust threshold flash: " +
                after.text.length +
                " chars, line height " +
                before.flashHeight +
                " -> " +
                after.flashHeight +
                ", view-settings top " +
                before.settingsTop +
                " -> " +
                after.settingsTop +
                ", field top " +
                before.fieldTop +
                " -> " +
                after.fieldTop,
        );

        assert(
            after.text === DUST_THRESHOLD_MESSAGE,
            "the field flashed something other than DUST_THRESHOLD_MESSAGE: " +
                JSON.stringify(after.text),
        );
        assert(
            after.flashHeight === before.flashHeight,
            "the message does not fit the reserved line: " +
                before.flashHeight +
                "px empty vs " +
                after.flashHeight +
                "px with the message. Shorten DUST_THRESHOLD_MESSAGE",
        );
        assert(
            after.settingsTop === before.settingsTop,
            "the settings view moved " +
                (after.settingsTop - before.settingsTop) +
                "px when the message appeared",
        );
        assert(
            after.fieldTop === before.fieldTop,
            "the dust threshold field moved " +
                (after.fieldTop - before.fieldTop) +
                "px when the message appeared",
        );
    } finally {
        await page.close();
    }
});

// --------------------------------------------- confirmation screen (#238)
//
// The screen that decides what gets signed. The arithmetic underneath it
// lives in src/shared/txValidation.js and is unit tested there; what these
// tests cover is the wiring — which number reaches the gate, when the gate
// re-runs, what the fee block renders, and whether Send is enabled.
//
// The load-bearing one is "gates on the fee RESERVE": the confirmation
// screen quotes the ESTIMATE and gates on the RESERVE, and issue #154 was
// the gate reading the quoted number. Every other assertion here would
// survive that mutation, so the funded and gap sends are deliberately sized
// on opposite sides of the reserve while sitting on the same side of the
// estimate.

// The balance the funded fixture serves, and the amounts sent against it.
const FUNDED_ETH_WEI = 10n ** 18n;
const FUNDED_ETH_TEXT = "1.0";
const COMFORTABLE_AMOUNT = "0.1";
const OVER_BALANCE_AMOUNT = "2.0";

// A send the balance covers to the wei once the ESTIMATE is added, and does
// not cover once the RESERVE is. Sending this is allowed by a gate reading
// the estimate and refused by a gate reading the reserve, which is the whole
// discrimination these tests exist to make.
const GAP_AMOUNT = formatEther(FUNDED_ETH_WEI - FEE_ESTIMATE_WEI);

// The ERC-20 side. The ETH balance is set to exactly the estimate for the
// fee test: it covers the expected cost to the wei and falls short of the
// reserve, so the same swap flips this assertion too — through a different
// balance and a different message than the ETH path uses.
const TOKEN_BALANCE_TEXT = "1.5";
const TOKEN_AMOUNT = "0.25";
const OVER_TOKEN_AMOUNT = "9.0";
const FEE_ONLY_ETH_WEI = FEE_ESTIMATE_WEI;

function toHexWei(wei) {
    return "0x" + wei.toString(16);
}

// A fee in wei as the confirmation screen writes it. Deliberately a second
// implementation of formatFeeEth() from src/popup/views/confirmTx.js rather
// than an import of it: that module pulls in the whole popup and cannot be
// required outside a browser, and asserting against an independent rendering
// is stronger than asserting a function equals itself.
function feeEth(wei) {
    const parts = formatEther(wei).split(".");
    const dec =
        parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
    return parts[0] + "." + dec + " ETH";
}

// What the confirmation screen is showing right now, read out of the DOM in
// one pass: whether sending is allowed, which reason it is giving, what the
// fee block says, and how tall the whole view is.
async function confirmState(page) {
    return page.evaluate(() => {
        const el = (id) => document.getElementById(id);
        // Both mechanisms matter. The two fee messages are dropped with
        // display:none for the transaction type they cannot apply to, and
        // shown or hidden with visibility for the one they can.
        const shown = (id) => {
            const cs = getComputedStyle(el(id));
            return cs.display !== "none" && cs.visibility === "visible";
        };
        return {
            height: el("view-confirm-tx").getBoundingClientRect().height,
            type: el("confirm-type").textContent.trim(),
            balance: el("confirm-balance").textContent.trim(),
            fee: el("confirm-fee-amount").textContent.trim(),
            reserve: el("confirm-fee-reserve").textContent.trim(),
            reserveShown: shown("confirm-fee-reserve"),
            errors: shown("confirm-errors")
                ? el("confirm-errors").textContent.trim()
                : "",
            amountFeeError: shown("confirm-amount-fee-error"),
            gasError: shown("confirm-gas-error"),
            feeUnknownError: shown("confirm-fee-unknown-error"),
            sendDisabled: el("btn-confirm-send").disabled,
        };
    });
}

async function waitForEstimate(page) {
    await page.waitForFunction(
        () =>
            document.getElementById("confirm-fee-amount").textContent.trim() !==
            "Estimating...",
        null,
        { timeout: 60000 },
    );
}

async function backToAddress(page) {
    if (await page.isVisible("#view-confirm-tx")) {
        await page.click("#btn-confirm-back");
        await visible(page, "#view-send");
    }
    if (await page.isVisible("#view-send")) {
        await page.click("#btn-send-back");
    }
    await openAddressDetail(page);
}

// Drive the popup to the confirmation screen for one send.
//
// It waits for the send screen to be showing `balance` before filling
// anything in. That figure is the exact number the spend gate compares
// against, so waiting for it — rather than for a refresh to have probably
// landed — is what keeps every assertion below deterministic after a
// fixture change.
async function goToConfirm(page, { token, balance, amount }) {
    await backToAddress(page);
    await page.click("#btn-send");
    await visible(page, "#view-send");
    await page.selectOption("#send-token", token);
    await page.waitForFunction(
        (want) =>
            document.getElementById("send-balance").textContent.trim() === want,
        "Current balance: " + balance,
        { timeout: 60000 },
    );
    await page.fill("#send-to", STUB_COUNTERPARTY);
    await page.fill("#send-amount", amount);
    await page.click("#btn-send-review");
    await visible(page, "#view-confirm-tx");
}

// A balance as the main view renders it: balanceLinesForAddress() writes
// every quantity with four decimal places.
function quantity(wei) {
    return parseFloat(formatEther(wei)).toFixed(4);
}

// Wait on the main view until a changed balance fixture has been picked up.
//
// Deliberately not a reload: the popup re-refreshes on a 10-second timer by
// itself, and reloading aborts whatever fetch the home screen has open at
// that instant, which the extension reports through log.errorf and the
// harness — correctly — fails the run on.
//
// It also deliberately settles on MAIN rather than on the address screen.
// The address screen builds the send screen's token dropdown once, from the
// balances it holds at that moment, and nothing rebuilds it when a later
// refresh arrives, so entering it early leaves a dropdown with no token in
// it and the ERC-20 path unreachable.
async function settleOnMain(env, { ethWei, expectToken }) {
    await backToAddress(env.page);
    await env.page.click("#btn-address-back");
    await visible(env.page, "#view-main");
    await env.page.waitForFunction(
        (want) =>
            document.getElementById("wallet-list").textContent.includes(want),
        quantity(ethWei),
        { timeout: 60000 },
    );
    if (expectToken) {
        await visible(
            env.page,
            '#wallet-list [data-token="' + STUB_TOKEN.address + '"]',
            60000,
        );
    }
}

test("ConfirmTx blocks sending while the fee estimate is pending (#238)", async (env) => {
    env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
    env.routeOpts.seedTokenBalance = true;
    await settleOnMain(env, {
        ethWei: FUNDED_ETH_WEI,
        expectToken: true,
    });

    env.routeOpts.holdGasEstimate = true;
    await goToConfirm(env.page, {
        token: "ETH",
        balance: FUNDED_ETH_TEXT + " ETH",
        amount: COMFORTABLE_AMOUNT,
    });

    const st = await confirmState(env.page);
    env.ethPendingHeight = st.height;
    console.log("# confirm-tx ETH view height: " + st.height + "px");
    assert(
        st.type === "Native ETH transfer",
        "unexpected transaction type: " + JSON.stringify(st.type),
    );
    assert(
        st.fee === "Estimating...",
        "the fee line is not showing the pending placeholder: " +
            JSON.stringify(st.fee),
    );
    assert(!st.reserveShown, "the reserve line is shown before any estimate");
    assert(
        st.sendDisabled,
        "Send is enabled while the fee estimate is still in flight",
    );
    assert(
        !st.feeUnknownError,
        "the estimate-failed message is shown for an estimate that is merely pending",
    );
    assert(
        !st.amountFeeError && !st.gasError && st.errors === "",
        "a balance message is shown before the fee is known",
    );
});

test("ConfirmTx enables Send once the estimate lands, quoting both numbers (#238)", async (env) => {
    env.routeOpts.holdGasEstimate = false;
    await waitForEstimate(env.page);

    const st = await confirmState(env.page);
    assert(
        st.balance === FUNDED_ETH_TEXT + " ETH",
        "the confirmation screen shows the wrong balance: " +
            JSON.stringify(st.balance),
    );
    assert(
        st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
        "the fee line does not quote the estimate: " + JSON.stringify(st.fee),
    );
    assert(st.reserveShown, "the reserve line is not shown once the fee lands");
    assert(
        st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved",
        "the reserve line does not quote the reserve: " +
            JSON.stringify(st.reserve),
    );
    assert(
        !st.sendDisabled,
        "Send is disabled for a comfortably funded transfer",
    );
    assert(
        st.errors === "" &&
            !st.amountFeeError &&
            !st.gasError &&
            !st.feeUnknownError,
        "a balance message is shown for a comfortably funded transfer",
    );
    assert(
        st.height === env.ethPendingHeight,
        "the view changed height when the estimate landed: " +
            env.ethPendingHeight +
            "px -> " +
            st.height +
            "px",
    );
});

// The one that closes the hole. Everything else here survives a gate that
// reads the displayed estimate instead of the reserve; this does not.
test("ConfirmTx gates on the fee RESERVE, not the displayed estimate (#238)", async (env) => {
    await goToConfirm(env.page, {
        token: "ETH",
        balance: FUNDED_ETH_TEXT + " ETH",
        amount: GAP_AMOUNT,
    });
    await waitForEstimate(env.page);

    const st = await confirmState(env.page);
    // Printed on every run, pass or fail: the two fee numbers and the gate's
    // decision side by side is the measurement this test is really making.
    console.log(
        "# gate probe: balance=" +
            FUNDED_ETH_TEXT +
            " ETH amount=" +
            GAP_AMOUNT +
            " estimate=" +
            feeEth(FEE_ESTIMATE_WEI) +
            " reserve=" +
            feeEth(FEE_RESERVE_WEI) +
            " sendDisabled=" +
            st.sendDisabled +
            " amountFeeError=" +
            st.amountFeeError,
    );
    assert(
        st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
        "the screen is not quoting the estimate, so this send is not in the gap: " +
            JSON.stringify(st.fee),
    );
    assert(
        st.sendDisabled,
        "Send is ENABLED for a transfer the fee RESERVE does not cover — the " +
            "spend gate is reading the displayed estimate, which is issue #154",
    );
    assert(
        st.amountFeeError,
        "the amount-plus-fee message is not shown for a send the reserve does not cover",
    );
    assert(
        st.height === env.ethPendingHeight,
        "the over-budget state is a different height than the pending state: " +
            env.ethPendingHeight +
            "px -> " +
            st.height +
            "px",
    );
});

test("ConfirmTx refuses a send that exceeds the balance outright (#238)", async (env) => {
    await goToConfirm(env.page, {
        token: "ETH",
        balance: FUNDED_ETH_TEXT + " ETH",
        amount: OVER_BALANCE_AMOUNT,
    });
    await waitForEstimate(env.page);

    const st = await confirmState(env.page);
    const want =
        "Insufficient balance. You have " +
        FUNDED_ETH_TEXT +
        " ETH but are trying to send " +
        OVER_BALANCE_AMOUNT +
        " ETH.";
    assert(
        st.errors === want,
        "wrong over-balance message: " + JSON.stringify(st.errors),
    );
    assert(st.sendDisabled, "Send is enabled for a send that exceeds balance");
    assert(
        !st.amountFeeError,
        "the amount-plus-fee message is shown for an amount that alone exceeds the balance",
    );
});

test("ConfirmTx refuses to send when the fee estimate fails, with its own message (#238)", async (env) => {
    // The refusal is logged by confirmTx via log.errorf, i.e. console.error,
    // which fails a test on its own. Declaring it here consumes exactly that
    // one record — and fails this test if it never arrives.
    env.errors.expect(
        "confirmTx logging the failed gas estimate",
        /gas estimation failed/,
    );
    env.routeOpts.failGasEstimate = true;
    env.routeOpts.holdGasEstimate = true;
    await goToConfirm(env.page, {
        token: "ETH",
        balance: FUNDED_ETH_TEXT + " ETH",
        amount: COMFORTABLE_AMOUNT,
    });

    const pending = await confirmState(env.page);
    assert(
        pending.fee === "Estimating..." && pending.sendDisabled,
        "the screen is not in the pending state before the estimate fails",
    );

    env.routeOpts.holdGasEstimate = false;
    await waitForEstimate(env.page);

    const st = await confirmState(env.page);
    env.routeOpts.failGasEstimate = false;
    assert(
        st.fee === "Unable to estimate",
        "the fee line does not report the failure: " + JSON.stringify(st.fee),
    );
    assert(
        !st.reserveShown,
        "the reserve line is shown after a failed estimate",
    );
    assert(
        st.feeUnknownError,
        "the estimate-failed message is not shown after a failed estimate",
    );
    assert(
        !st.amountFeeError && !st.gasError && st.errors === "",
        "a balance message is shown for an estimate that simply failed",
    );
    assert(
        st.sendDisabled,
        "Send is enabled with no usable fee estimate — an unknown fee is being treated as zero",
    );
    assert(
        st.height === pending.height,
        "the view changed height when the estimate failed: " +
            pending.height +
            "px -> " +
            st.height +
            "px",
    );
});

test("ConfirmTx drives the ERC-20 path from pending to funded (#238)", async (env) => {
    env.routeOpts.holdGasEstimate = true;
    await goToConfirm(env.page, {
        token: STUB_TOKEN.address,
        balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
        amount: TOKEN_AMOUNT,
    });

    const pending = await confirmState(env.page);
    env.erc20PendingHeight = pending.height;
    console.log("# confirm-tx ERC-20 view height: " + pending.height + "px");
    assert(
        pending.type === "ERC-20 token transfer (" + STUB_TOKEN.symbol + ")",
        "unexpected transaction type: " + JSON.stringify(pending.type),
    );
    assert(
        pending.balance === TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
        "the ERC-20 screen shows the wrong balance: " +
            JSON.stringify(pending.balance),
    );
    assert(
        pending.fee === "Estimating..." && pending.sendDisabled,
        "the ERC-20 screen does not block sending while its estimate is pending",
    );

    env.routeOpts.holdGasEstimate = false;
    await waitForEstimate(env.page);

    const st = await confirmState(env.page);
    assert(
        st.fee === "~" + feeEth(FEE_ESTIMATE_WEI),
        "the ERC-20 fee line does not quote the estimate: " +
            JSON.stringify(st.fee),
    );
    assert(
        st.reserveShown &&
            st.reserve === "up to " + feeEth(FEE_RESERVE_WEI) + " reserved",
        "the ERC-20 fee block does not quote the reserve: " +
            JSON.stringify(st.reserve),
    );
    assert(!st.sendDisabled, "Send is disabled for a funded ERC-20 transfer");
    assert(
        st.height === pending.height,
        "the ERC-20 view changed height when the estimate landed: " +
            pending.height +
            "px -> " +
            st.height +
            "px",
    );
});

test("ConfirmTx refuses an ERC-20 send that exceeds the token balance (#238)", async (env) => {
    await goToConfirm(env.page, {
        token: STUB_TOKEN.address,
        balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
        amount: OVER_TOKEN_AMOUNT,
    });
    await waitForEstimate(env.page);

    const st = await confirmState(env.page);
    const want =
        "Insufficient " +
        STUB_TOKEN.symbol +
        " balance. You have " +
        TOKEN_BALANCE_TEXT +
        " " +
        STUB_TOKEN.symbol +
        " but are trying to send " +
        OVER_TOKEN_AMOUNT +
        " " +
        STUB_TOKEN.symbol +
        ".";
    assert(
        st.errors === want,
        "wrong over-token-balance message: " + JSON.stringify(st.errors),
    );
    assert(
        st.sendDisabled,
        "Send is enabled for an ERC-20 transfer that exceeds the token balance",
    );
    assert(
        !st.gasError,
        "the ERC-20 gas message is shown for an ETH balance that covers the fee",
    );
});

// The same swap, through the other balance and the other message: here the
// token balance is ample and it is the ETH balance that must cover the fee.
// It is set to exactly the estimate, so an estimate-reading gate lets this
// through and the reserve-reading gate refuses it.
test("ConfirmTx gates the ERC-20 fee on the RESERVE, with the ERC-20 message (#238)", async (env) => {
    env.routeOpts.ethBalanceWei = toHexWei(FEE_ONLY_ETH_WEI);
    await settleOnMain(env, {
        ethWei: FEE_ONLY_ETH_WEI,
        expectToken: true,
    });
    await goToConfirm(env.page, {
        token: STUB_TOKEN.address,
        balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
        amount: TOKEN_AMOUNT,
    });
    await waitForEstimate(env.page);

    const st = await confirmState(env.page);
    console.log(
        "# erc-20 gate probe: ethBalance=" +
            formatEther(FEE_ONLY_ETH_WEI) +
            " estimate=" +
            feeEth(FEE_ESTIMATE_WEI) +
            " reserve=" +
            feeEth(FEE_RESERVE_WEI) +
            " sendDisabled=" +
            st.sendDisabled +
            " gasError=" +
            st.gasError,
    );
    assert(
        st.sendDisabled,
        "Send is ENABLED for an ERC-20 transfer whose fee RESERVE exceeds the " +
            "ETH balance — the spend gate is reading the displayed estimate (#154)",
    );
    assert(
        st.gasError,
        "the ERC-20 network-fee message is not shown when the ETH balance cannot cover the reserve",
    );
    assert(
        !st.amountFeeError,
        "the native-ETH over-budget message is shown on an ERC-20 transfer",
    );
    assert(
        st.errors === "",
        "a token-balance message is shown for a transfer the token balance covers: " +
            JSON.stringify(st.errors),
    );
    assert(
        st.height === env.erc20PendingHeight,
        "the ERC-20 fee-error state is a different height than its pending state: " +
            env.erc20PendingHeight +
            "px -> " +
            st.height +
            "px",
    );
});

test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee problem (#238)", async (env) => {
    env.errors.expect(
        "confirmTx logging the failed ERC-20 gas estimate",
        /gas estimation failed/,
    );
    env.routeOpts.failGasEstimate = true;
    await goToConfirm(env.page, {
        token: STUB_TOKEN.address,
        balance: TOKEN_BALANCE_TEXT + " " + STUB_TOKEN.symbol,
        amount: TOKEN_AMOUNT,
    });
    await waitForEstimate(env.page);

    const st = await confirmState(env.page);
    env.routeOpts.failGasEstimate = false;
    assert(
        st.fee === "Unable to estimate",
        "the ERC-20 fee line does not report the failure: " +
            JSON.stringify(st.fee),
    );
    assert(
        st.feeUnknownError,
        "the estimate-failed message is not shown on the ERC-20 path",
    );
    assert(
        !st.gasError,
        "the ERC-20 network-fee message is shown for a fee that is unknown rather than unaffordable",
    );
    assert(
        st.sendDisabled,
        "Send is enabled on the ERC-20 path with no usable fee estimate",
    );
    assert(
        st.height === env.erc20PendingHeight,
        "the ERC-20 estimate-failed state is a different height than its pending state: " +
            env.erc20PendingHeight +
            "px -> " +
            st.height +
            "px",
    );
});

// ---------------------------------------------------------------- runner

async function main() {
    // A suite that runs nothing must never report success. If a refactor
    // drops the registrations above, or a require() of this file stops
    // reaching them, the only honest outcome is a red run — reporting
    // "0/0 passed" and exiting 0 is the same vacuous-check failure this
    // whole harness exists to prevent.
    if (tests.length === 0) {
        console.log("1..0");
        console.log("# FAILED: the e2e suite registered no tests");
        process.exitCode = 1;
        return;
    }

    // Every fixture switch the suite can flip, declared in one place so the
    // starting state of a run is readable without hunting through tests.
    const routeOpts = {
        seedTokenTransfer: false,
        seedTokenBalance: false,
        ethBalanceWei: null,
        failGasEstimate: false,
        holdGasEstimate: false,
    };

    let session;
    try {
        session = await launch(routeOpts);
    } catch (e) {
        // Never skip and report success: a browser we cannot start, or
        // one whose network interception is not in force, is a failure of
        // the suite, not an absent one.
        console.error("e2e: cannot run the suite: " + e.message);
        process.exitCode = 1;
        return;
    }

    console.log("# extension id: " + session.extensionId);
    console.log("1.." + tests.length);

    const env = {
        ctx: session.ctx,
        popupUrl: session.popupUrl,
        routeOpts,
        page: null,
        // The error collector, so a test that drives a failure path on
        // purpose can declare the console.error it is about to provoke.
        errors: session.errors,
        // The recovery phrase of the wallet created in test 2, so later
        // tests can assert on the real secret rather than its shape.
        phrase: null,
        // Confirmation-screen heights, measured in the pending state and
        // compared against every later state of the same screen.
        ethPendingHeight: null,
        erc20PendingHeight: null,
    };

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