// Firefox end-to-end suite: drives the real popup in a real Firefox with
// the unpacked MV2 build installed as a temporary add-on, and fails the run
// on any uncaught error coming from an extension source.
//
// Run via script/test-e2e-firefox, which builds dist/firefox/ and the pinned
// container. The extension directory is the one argument.
//
//   node tests/e2e/firefox/run.js [dist/firefox]
//
// Deliberately not part of script/check, and deliberately not named
// *.test.js: REPO_POLICIES.md caps make test at 20 seconds and a browser
// suite does not fit.
//
// This shares no driver layer with the Chrome suite in tests/e2e/, and the
// UI steps below are written twice on purpose. Chrome runs on Playwright,
// which cannot see extension-page errors in Firefox at all (see the BiDi
// note in driver.js), so the two backends have no common substrate to
// abstract over. Duplicated steps do not pay for a shim; revisit if this
// suite grows to where they do. What IS shared is the dApp page fixture
// itself — DAPP_HTML, served here from loopback by dapp.js — so an assertion
// about the __dapp API means the same thing on both browsers.
//
// The dApp steps need an http:// origin, which --network none was thought to
// rule out. It does not: loopback survives it, so the page and the stub node
// are served from 127.0.0.1 inside the container and the run reaches nothing
// but this process. See tests/e2e/firefox/dapp.js.
//
// LIMITATION, and the difference from the Chrome suite worth knowing: error
// capture here is POLL-BASED, not event-streamed. The console service is
// drained at each step boundary, so an error is attributed to the step it
// was drained after, never to a moment within that step. What is drained
// covers the whole run from add-on install to the last drain below, which
// lands ~1.5s after the last step returns (500ms settle + 1000ms sleep +
// two drain round trips). That cut-off jitters run to run: three runs of
// throws at fixed offsets reported everything to +1.5s and one of them
// also +1.6s, and past it the browser is torn down first. Inside the
// window there is no race — the drain reads and clears in one chrome
// round trip — but there is a capacity limit: nsIConsoleService keeps
// only the newest 250 messages, so 400 throws in one step report as
// exactly 250. A clean run peaks at 4 of 250, so that is headroom today
// and not a guarantee for a step that logs heavily. The Chrome harness
// receives pageerror events as they happen and can say more. Do not read
// a green Firefox run as the same claim.

"use strict";

const fs = require("fs");
const path = require("path");

const {
    Transaction,
    formatEther,
    getAddress,
    getBytes,
    hexlify,
    parseEther,
    toQuantity,
    toUtf8Bytes,
    verifyMessage,
} = require("ethers");

const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network");

const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
const PASSWORD = "e2e-harness-password";

// Firefox installs the add-on and starts its background page asynchronously
// after the install call returns. Nothing observable marks the end of that,
// so the popup's own first render is the signal we wait on instead.
const STEP_TIMEOUT_MS = 120000;

const steps = [];

function step(name, fn) {
    steps.push({ name, fn });
}

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

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

// ------------------------------------------------------------- steps

step("popup loads and reaches the welcome view", async (env) => {
    const d = env.driver;
    await d.navigate(POPUP_URL);
    await d.waitVisible("#view-welcome", STEP_TIMEOUT_MS);
    const title = await d.title();
    assert(title === "AutistMask", "unexpected popup title: " + title);
});

step("wallet creation through the UI reaches the main view", async (env) => {
    const d = env.driver;
    await d.click("#btn-welcome-add");
    await d.waitVisible("#view-add-wallet");
    await d.click("#btn-generate-phrase");
    await d.waitFor(
        "a generated recovery phrase of at least 12 words",
        `const el = document.getElementById("wallet-mnemonic");
         return !!el && el.value.trim().split(/\\s+/).length >= 12;`,
    );
    env.phrase = (await d.value("#wallet-mnemonic")).trim();

    await d.fill("#add-wallet-password", PASSWORD);
    await d.fill("#add-wallet-password-confirm", PASSWORD);
    await d.click("#btn-add-wallet-confirm");
    // Argon2id under libsodium, for real, so this is the slow one.
    await d.waitVisible("#view-main", STEP_TIMEOUT_MS);

    assert(
        env.phrase.split(/\s+/).length >= 12,
        "wallet creation did not yield a recovery phrase",
    );
    const addrs = await d.count("#wallet-list .btn-addr-info");
    assert(addrs > 0, "no addresses rendered in the wallet list");
});

step("add token screen opens from address detail", async (env) => {
    const d = env.driver;
    if (!(await d.isVisible("#view-address"))) {
        await d.waitVisible("#view-main");
        await d.click("#wallet-list .btn-addr-info");
    }
    await d.waitVisible("#view-address");

    await d.click("#btn-add-token");
    // Reported with the view it actually stayed on: a screen that does
    // not change is the symptom a missing import produces, and naming
    // the screen is what makes that diagnosable.
    try {
        await d.waitVisible("#view-add-token");
    } catch (e) {
        throw new Error(
            e.message + "; current view is " + (await d.currentView()),
        );
    }

    const picks = await d.count("#common-token-list .common-token");
    assert(picks > 0, "no common-token quick-pick buttons rendered");
});

// ------------------------------------------------- the dApp round trips
//
// Everything above drives the popup on its own. From here the page, the
// content script, the inpage provider, the background page and the approval
// window all have to work together — the paths
// https://git.eeqj.de/sneak/AutistMask/issues/153 rewrote, and the ones no
// Firefox test reached before. They are asserted here because nothing else
// covers them on this browser, not because they were broken: these steps
// pass against the pre-refactor callback code too, which is how the issue's
// premise was refuted.
//
// The shape is the Chrome suite's (tests/e2e/run.js, the #183 section) and
// the assertions mean the same things:
//
//  - the signature is recovered here, in the runner, from the artifact the
//    extension produced, and compared against the address read out of
//    extension storage. The background verifies too; these assertions do not
//    lean on that, because a test that trusted the wallet's own verdict would
//    pass against a wallet that verified nothing.
//  - the transaction is asserted against the raw signed transaction that
//    reached the stub node, not against anything the extension reported.
//
// What this does NOT cover: a real dApp with real funds against a real
// network. The node is a fixture on loopback.

const SIGN_TEXT = "AutistMask e2e round trip: personal_sign";
const SIGN_HEX = hexlify(toUtf8Bytes(SIGN_TEXT));

const TX_VALUE_ETH = "0.0123";
const TX_VALUE_WEI = parseEther(TX_VALUE_ETH);
// Call data that decodes as nothing, so the screen assertion compares the
// calldata itself rather than a decoder's summary of it.
const TX_DATA = "0xdeadbeef" + "01".repeat(28);

const USER_REJECTION_MESSAGE = "User rejected the request.";

// Read the extension's persisted state, point its rpcUrl at the loopback stub
// node, and hand back the active address. Runs on the popup page, which is
// the one moz-extension:// document the suite has open and therefore the only
// place the storage API is reachable from.
async function pointAtStubNode(d, rpcUrl) {
    const outcome = await d.executeAsync(
        `const done = arguments[arguments.length - 1];
         const rpcUrl = arguments[0];
         const api = typeof browser !== "undefined" ? browser : chrome;
         Promise.resolve(api.storage.local.get("autistmask"))
             .then((r) => {
                 const s = r.autistmask;
                 if (!s) throw new Error("the extension has no persisted state");
                 s.rpcUrl = rpcUrl;
                 const w = s.wallets && s.wallets[0];
                 const first = w && w.addresses && w.addresses[0];
                 const address = s.activeAddress || (first && first.address);
                 if (!address) throw new Error("the extension holds no address");
                 return Promise.resolve(api.storage.local.set({ autistmask: s }))
                     .then(() => done({ address: address }));
             })
             .catch((e) => done({ error: String((e && e.message) || e) }));`,
        [rpcUrl],
    );
    assert(
        outcome && !outcome.error,
        "could not point the extension at the stub node: " +
            (outcome && outcome.error),
    );
    return getAddress(outcome.address);
}

// 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 window
// for them, which is an ordinary window handle here.
async function waitForApprovalWindow(d, timeout = 30000) {
    const deadline = Date.now() + timeout;
    for (;;) {
        const handle = await d.findWindow((u) => u.includes("?approval="));
        if (handle) return handle;
        if (Date.now() > deadline) {
            throw new Error(
                "the extension opened no approval window within " +
                    timeout +
                    "ms",
            );
        }
        await sleep(100);
    }
}

function startRequest(d, key, method, params) {
    return d.execute(
        "window.__dapp.start(arguments[0], arguments[1], arguments[2]);" +
            " return true;",
        [key, method, params],
    );
}

// The settled outcome of a parked request, or {settled:"pending"} if it is
// still outstanding. A bounded wait rather than a bare await: "returns a
// rejection rather than hanging" is one of the things under test, and an
// await would report a hang as a step timeout with no indication of which
// call never settled.
function settleRequest(d, key, timeout = 45000) {
    return d.executeAsync(
        `const done = arguments[arguments.length - 1];
         const key = arguments[0];
         const timeout = arguments[1];
         Promise.race([
             window.__dapp.settle(key),
             new Promise((r) => setTimeout(() => r({ settled: "pending" }), timeout)),
         ]).then(done, (e) => done({ settled: "error", message: String(e) }));`,
        [key, timeout],
    );
}

// Every AUTISTMASK_* message that has crossed between the page and the
// content script. This is the boundary half of the rejection assertion: the
// code has to be on the wire as well as on the Error the page catches, so a
// pass cannot come from the provider inventing one.
function dappMessages(d, type) {
    return d.execute(
        // `want` is bound outside the callback deliberately: inside it,
        // arguments[0] is the message being tested, not the script argument,
        // and the filter silently matches nothing.
        "var want = arguments[0];" +
            " return window.__dapp.messages.filter(function (m) {" +
            " return !want || m.type === want; });",
        [type || null],
    );
}

async function lastResponseError(d) {
    const responses = await dappMessages(d, "AUTISTMASK_RESPONSE");
    const last = responses[responses.length - 1];
    assert(last, "the page received no AUTISTMASK_RESPONSE at all");
    return last.error || null;
}

// A rejected prompt, asserted at both ends: the page's promise rejected
// rather than hanging or resolving, and the response that crossed the
// boundary carried EIP-1193 code 4001.
async function assertUserRejection(d, key, label) {
    const outcome = await settleRequest(d, 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(d);
    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,
    );
}

step("the loopback dApp page gets the real inpage provider", async (env) => {
    const d = env.driver;

    // The popup is still the current window; point the extension at the stub
    // node from there, then reload it so its in-memory copy of the state
    // carries the new rpcUrl and cannot save the old one back over it.
    env.address = await pointAtStubNode(d, env.server.rpcUrl);
    await d.navigate(POPUP_URL);
    await d.waitVisible("#view-main", STEP_TIMEOUT_MS);
    env.popupWindow = await d.currentWindow();

    env.dappWindow = await d.newWindow("tab");
    await d.switchToWindow(env.dappWindow);
    await d.navigate(env.server.url);

    // window.ethereum is not the fixture's doing — it is the shipped content
    // script, injected into a real http:// origin. Waiting for it is waiting
    // for the real provider to have installed itself.
    await d.waitFor(
        "the injected EIP-1193 provider and the test page API",
        "return !!window.ethereum && !!window.__dapp;",
        [],
        STEP_TIMEOUT_MS,
    );

    // EIP-6963, asked of the provider itself. The announcement carries the
    // uuid src/content/index.js reads out of extension storage — call site 1
    // in the issue — and it has to name this extension and hand back the very
    // object on window.ethereum.
    const announced = await d.executeAsync(
        `const done = arguments[arguments.length - 1];
         const onAnnounce = (e) => {
             window.removeEventListener("eip6963:announceProvider", onAnnounce);
             done({
                 rdns: e.detail.info.rdns,
                 uuid: e.detail.info.uuid,
                 isWindowEthereum: e.detail.provider === window.ethereum,
             });
         };
         window.addEventListener("eip6963:announceProvider", onAnnounce);
         window.dispatchEvent(new Event("eip6963:requestProvider"));
         setTimeout(() => done(null), 15000);`,
    );
    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",
    );
    assert(
        typeof announced.uuid === "string" && announced.uuid.length === 36,
        "the announcement carries no stored provider uuid: " +
            JSON.stringify(announced.uuid),
    );

    // A full page -> content script -> background round trip that needs no
    // approval, so the relay is proven before any prompt is driven. This is
    // call site 2, the one that used to fail for every window.ethereum
    // request a dApp made.
    const chainId = await d.executeAsync(
        `const done = arguments[arguments.length - 1];
         window.ethereum.request({ method: "eth_chainId" }).then(
             (r) => done({ ok: r }),
             (e) => done({ err: String((e && e.message) || e) }),
         );`,
    );
    assert(
        chainId && chainId.ok === "0x1",
        "eth_chainId did not round trip through the extension: " +
            JSON.stringify(chainId),
    );
    console.log(
        "# dapp origin " + env.server.origin + " active address " + env.address,
    );
});

step(
    "eth_requestAccounts approved returns the selected address",
    async (env) => {
        const d = env.driver;
        await d.switchToWindow(env.dappWindow);
        await startRequest(d, "accounts", "eth_requestAccounts", []);

        const popup = await waitForApprovalWindow(d);
        await d.switchToWindow(popup);
        await d.waitVisible("#view-approve-site");

        const hostname = await d.text("#approve-hostname");
        assert(
            hostname === "127.0.0.1",
            "the site prompt names the wrong origin: " +
                JSON.stringify(hostname),
        );
        const shown = await d.text("#approve-address");
        assert(
            shown.toLowerCase().includes(env.address.toLowerCase()),
            "the site prompt shows the wrong address: " + JSON.stringify(shown),
        );

        // Remembered, so the origin stays authorized for the sign and transaction
        // steps below.
        const checked = await d.execute(
            'return document.getElementById("approve-remember").checked;',
        );
        if (!checked) await d.click("#approve-remember");
        await d.click("#btn-approve");

        // The approve button closes its own window, so get off it before asking
        // the page anything.
        await d.switchToWindow(env.dappWindow);
        const outcome = await settleRequest(d, "accounts");
        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.address,
            "eth_requestAccounts returned " +
                outcome.result[0] +
                ", not the selected address " +
                env.address,
        );
    },
);

step(
    "personal_sign returns a signature that recovers to the address",
    async (env) => {
        const d = env.driver;
        await d.switchToWindow(env.dappWindow);
        await startRequest(d, "sign", "personal_sign", [SIGN_HEX, env.address]);

        const popup = await waitForApprovalWindow(d);
        await d.switchToWindow(popup);
        await d.waitVisible("#view-approve-sign");

        const screen = await d.execute(
            `return {
             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 === "127.0.0.1",
            "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.address.toLowerCase()),
            "the sign prompt shows the wrong signing address: " +
                JSON.stringify(screen.from),
        );

        await d.fill("#approve-sign-password", PASSWORD);
        await d.click("#btn-approve-sign");

        await d.switchToWindow(env.dappWindow);
        const outcome = await settleRequest(d, "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.address,
        );
        assert(
            recovered === env.address,
            "the personal_sign signature recovers to " +
                recovered +
                ", not to the approved address " +
                env.address,
        );
    },
);

step(
    "eth_sendTransaction shows the transaction and returns its hash",
    async (env) => {
        const d = env.driver;
        const before = env.server.broadcast.length;

        await d.switchToWindow(env.dappWindow);
        await startRequest(d, "tx", "eth_sendTransaction", [
            {
                from: env.address,
                to: STUB_COUNTERPARTY,
                value: toQuantity(TX_VALUE_WEI),
                data: TX_DATA,
            },
        ]);

        const popup = await waitForApprovalWindow(d);
        await d.switchToWindow(popup);
        await d.waitVisible("#view-approve-tx");

        const screen = await d.execute(
            `return {
             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 === "127.0.0.1",
            "the transaction prompt names the wrong origin: " +
                JSON.stringify(screen.hostname),
        );
        assert(
            screen.from.toLowerCase().includes(env.address.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),
        );

        await d.fill("#approve-tx-password", PASSWORD);
        await d.click("#btn-approve-tx");

        // The approval window hands off to the wait screen rather than closing,
        // and the hash it shows is asserted before it is retired: left open it
        // polls the stub node for a receipt for the rest of the run.
        await d.waitVisible("#view-wait-tx", STEP_TIMEOUT_MS);
        const waitHash = await d.text("#wait-tx-hash");

        await d.switchToWindow(env.dappWindow);
        const outcome = await settleRequest(d, "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.
        assert(
            env.server.broadcast.length === before + 1,
            "expected exactly one raw transaction to reach the node, got " +
                (env.server.broadcast.length - before),
        );
        const signed = Transaction.from(
            env.server.broadcast[env.server.broadcast.length - 1],
        );
        console.log(
            "# eth_sendTransaction: signer=" +
                getAddress(signed.from) +
                " to=" +
                getAddress(signed.to) +
                " value=" +
                formatEther(signed.value) +
                " chainId=" +
                signed.chainId,
        );
        assert(
            getAddress(signed.from) === env.address,
            "the broadcast transaction was signed by " +
                getAddress(signed.from) +
                ", not by the approved address " +
                env.address,
        );
        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,
        );
        assert(
            waitHash.includes(signed.hash),
            "the wait screen shows a different hash: " +
                JSON.stringify(waitHash),
        );

        await d.switchToWindow(popup);
        await d.closeWindow(env.dappWindow);
    },
);

step(
    "closing an approval window rejects the request with 4001",
    async (env) => {
        const d = env.driver;
        const before = env.server.broadcast.length;

        await d.switchToWindow(env.dappWindow);
        await startRequest(d, "sign-closed", "personal_sign", [
            SIGN_HEX,
            env.address,
        ]);

        const popup = await waitForApprovalWindow(d);
        await d.switchToWindow(popup);
        await d.waitVisible("#view-approve-sign");

        // Closed, not rejected: this is the windows.onRemoved path, which can
        // only fire if windows.create() handed back a window id for the
        // approval to be matched against — call site 4 in the issue, and the
        // reason suppressing that write-back turns this step red.
        await d.closeWindow(env.dappWindow);

        await assertUserRejection(d, "sign-closed", "a closed approval window");
        assert(
            env.server.broadcast.length === before,
            "a closed approval window still put a transaction on the node",
        );
    },
);

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

// Uncaught extension errors that are known, tracked and deliberately
// tolerated, in the same spirit as ALLOWED_ERRORS in tests/e2e/harness.js:
// every entry names the issue that will delete it, and every occurrence is
// still printed, so tolerating one is visible in the log rather than silent.
// This is the only concession in an otherwise zero-tolerance policy.
const ALLOWED_ERRORS = [
    {
        // The site-connection buttons in src/popup/views/approval.js send
        // their decision and call window.close() on the next line. Firefox's
        // BaseContext.wrapPromise reports, through Cu.reportError, any
        // extension-API promise that settles after its context unloaded —
        // whether or not the caller attached a handler, so notify()'s catch
        // cannot suppress it.
        //
        // Pre-existing, and not introduced by the promise shim: the send was
        // already unawaited, and this suite is merely the first thing to
        // drive that window on Firefox. It is the same teardown ordering as
        // the issue below, whose fix — making the outcome independent of when
        // the popup closes — removes this entry with it.
        pattern: /Promise (?:resolved|rejected) after context unloaded/,
        source: /\/src\/popup\/index\.js$/,
        issue: "https://git.eeqj.de/sneak/AutistMask/issues/275",
    },
];

function allowedFor(e) {
    return ALLOWED_ERRORS.find(
        (a) => a.pattern.test(e.msg) && a.source.test(e.src),
    );
}

function formatError(e) {
    return (
        e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")"
    );
}

async function main() {
    // A suite that runs nothing must never report success.
    if (steps.length === 0) {
        console.log("1..0");
        console.log("# FAILED: the Firefox e2e suite registered no steps");
        process.exitCode = 1;
        return;
    }

    const extDir = path.resolve(REPO_ROOT, process.argv[2] || "dist/firefox");
    if (!fs.existsSync(path.join(extDir, "manifest.json"))) {
        console.error(
            "e2e-firefox: no unpacked build at " +
                extDir +
                " — run make build first",
        );
        process.exitCode = 1;
        return;
    }

    // Loopback survives --network none, so this is the http:// origin the
    // dApp steps need and the node they talk to. Started before the browser
    // so its url is available to the first step that asks for it.
    let server;
    try {
        server = await startDappServer();
    } catch (e) {
        console.error(
            "e2e-firefox: cannot serve the dApp fixture: " + e.message,
        );
        process.exitCode = 1;
        return;
    }
    console.log("# dapp fixture: " + server.url + " rpc " + server.rpcUrl);

    let driver;
    try {
        driver = await start();
        await driver.newSession();
        await driver.installAddon(extDir);
    } catch (e) {
        // A browser we cannot start is a failure of the suite, not an
        // absent suite. Never skip and report success.
        console.error("e2e-firefox: cannot run the suite: " + e.message);
        if (driver) await driver.quit().catch(() => {});
        await server.close();
        process.exitCode = 1;
        return;
    }

    const errors = new ConsoleErrors(driver, EXTENSION_ORIGIN);
    const env = {
        driver,
        server,
        phrase: null,
        address: null,
        dappWindow: null,
        popupWindow: null,
    };

    console.log("# extension origin: " + EXTENSION_ORIGIN);
    console.log("1.." + steps.length);

    let failed = 0;
    let n = 0;
    try {
        // Drain, never reset: anything the add-on logged while installing
        // and starting its background page has no earlier step to belong
        // to, so it is folded into step 1 below. Services.console.reset()
        // here would DELETE it instead, and a background page that throws
        // at the top of the file — a dead background page — would then
        // produce a fully green run.
        let installErrors = [];
        let installFailure = null;
        try {
            installErrors = await errors.take();
        } catch (e) {
            installFailure =
                "could not read the console after install: " + e.message;
        }

        for (const s of steps) {
            n += 1;
            let failure = null;
            try {
                await withTimeout(s.fn(env), s.name);
            } catch (e) {
                failure = e.message;
            }

            // Let anything the step provoked reach the console service
            // before draining it. Without this a failure logged on the
            // way out of the step lands in the next step's drain, which
            // still fails the run but blames the wrong step.
            await sleep(500);

            let found = [];
            try {
                found = await errors.take();
            } catch (e) {
                failure = failure || "could not read the console: " + e.message;
            }

            if (n === 1) {
                found = installErrors.concat(found);
                installErrors = [];
                failure = failure || installFailure;
                installFailure = null;
            }

            // Tolerated errors are set aside, never dropped: each one is
            // printed with the issue that keeps it on the list, so the
            // concession stays in the run output.
            const tolerated = found.filter((e) => allowedFor(e));
            found = found.filter((e) => !allowedFor(e));
            for (const e of tolerated) {
                console.log(
                    "# tolerated (" +
                        allowedFor(e).issue +
                        "): " +
                        formatError(e),
                );
            }

            // Any uncaught error from an extension source fails the step
            // that provoked it, whether or not its assertions passed.
            if (!failure && found.length > 0) {
                failure =
                    n === 1
                        ? "uncaught extension errors during add-on install, " +
                          "background startup or this step"
                        : "uncaught extension errors during this step";
            }

            if (failure) {
                failed += 1;
                console.log("not ok " + n + " - " + s.name);
                console.log("  " + failure);
                for (const e of found) console.log("  " + formatError(e));
            } else {
                console.log("ok " + n + " - " + s.name);
            }
        }

        // The tail: errors logged after the last step returned cannot be
        // blamed on any one step, but they are still reported and they
        // still fail the run.
        await sleep(1000);
        const trailingAll = await errors.take();
        for (const e of trailingAll.filter((x) => allowedFor(x))) {
            console.log(
                "# tolerated (" + allowedFor(e).issue + "): " + formatError(e),
            );
        }
        const trailing = trailingAll.filter((e) => !allowedFor(e));
        console.log(
            "# " +
                (steps.length - failed) +
                "/" +
                steps.length +
                " steps passed",
        );
        if (trailing.length > 0) {
            console.log(
                "# " +
                    trailing.length +
                    " extension error(s) recorded after the last step, not " +
                    "attributable to any single step:",
            );
            for (const e of trailing) console.log("#   " + formatError(e));
        }
        // A JSON-RPC method nothing answered means the extension asked the
        // node something this fixture does not model, and whatever depended
        // on the answer took the error branch instead. That is a hole in the
        // fixture, not a pass.
        if (server.unstubbed.length > 0) {
            console.log(
                "# FAILED: no fixture for JSON-RPC method(s) " +
                    [...new Set(server.unstubbed)].join(", "),
            );
            process.exitCode = 1;
        }

        if (failed > 0 || trailing.length > 0) {
            console.log("# FAILED");
            process.exitCode = 1;
        }
    } finally {
        await driver.quit().catch(() => {});
        await server.close();
    }
}

main().catch((e) => {
    console.error("e2e-firefox: " + (e && e.stack ? e.stack : e));
    process.exitCode = 1;
});
