fix: settle a site approval on the port that carries its teardown (closes #275)
All checks were successful
check / check (push) Successful in 38s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 23s

This commit was merged in pull request #289.
This commit is contained in:
2026-08-17 09:34:07 +02:00
parent 7690fe6429
commit 8fcdd8a053
5 changed files with 509 additions and 71 deletions

View File

@@ -2183,32 +2183,13 @@ async function reserveApprovalTab(env) {
// one down with it.
env.approvalTab = await env.ctx.newPage();
// The one accommodation this section makes to the shipped code, and the
// reason for it.
//
// Both approval buttons call runtime.sendMessage() and then window.close()
// on the next line. Closing this page disconnects the approval port, and
// the disconnect handler in src/background/index.js settles a pending
// site approval as a rejection. In a tab those two race and the teardown
// wins: the approve message is never acted on, and the page is told the
// user rejected. Measured — with the close left in place the approval
// resolves as a rejection every time; with it deferred it resolves as an
// approval every time.
//
// It is deferred, not removed: the harness closes the page itself once
// the outcome has been observed, which is what window.close() would have
// done, only after the message it was racing has been processed.
//
// This affects the site-connection prompt only. The sign and transaction
// prompts run in windows the extension opens itself, with window.close()
// untouched, and their disconnect handler deliberately keeps a tx or sign
// approval pending rather than rejecting it — so there is no race there
// to accommodate. Whether the same ordering holds in a real toolbar popup
// is not observable from a headless harness and is reported rather than
// assumed either way.
await env.approvalTab.addInitScript(() => {
window.close = function () {};
});
// 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;
@@ -2257,6 +2238,95 @@ async function closeApprovalPages(ctx) {
}
}
// 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
@@ -2477,7 +2547,11 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
// origin in deniedSites and every later test in this section is
// auto-rejected with no prompt at all, which would look like a pass.
await popup.uncheck("#approve-remember");
await popup.click("#btn-reject");
// 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,
@@ -2508,7 +2582,7 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
// does not, and the sign and transaction tests below all require the
// origin to still be authorized.
await popup.check("#approve-remember");
await popup.click("#btn-approve");
await clickAndClose(popup, "#btn-approve");
outcome = await settleRequest(env.dapp, "accounts");
} finally {
@@ -2616,7 +2690,7 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
await popup.click("#btn-reject-sign");
await clickAndClose(popup, "#btn-reject-sign");
await assertUserRejection(
env.dapp,
@@ -2719,7 +2793,7 @@ test("eth_signTypedData_v4 rejected returns a rejection to the page (#183)", asy
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
await popup.click("#btn-reject-sign");
await clickAndClose(popup, "#btn-reject-sign");
await assertUserRejection(
env.dapp,
@@ -2877,7 +2951,7 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-tx");
await popup.click("#btn-reject-tx");
await clickAndClose(popup, "#btn-reject-tx");
await assertUserRejection(
env.dapp,