fix: open an approval window while another one has focus (closes #290)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s

The background centred each approval window on the last focused window,
which could be an earlier approval window still open; headless Chrome
reports one as 1280x720, the browser refused the resulting position, and
the request failed with no window. It now centres only on a browser
window, and when the browser refuses a position it asks again without one.

In the Chrome suite a test could raise its prompt while the previous
test's window was still closing. After a passed test the runner now gives
approval windows five seconds to close and fails the test if one is still
open; after a failed test it closes them.

Model: opus-5-5
This commit is contained in:
2026-10-05 05:49:39 +00:00
parent a207ac70bd
commit 7f9b856349
5 changed files with 220 additions and 18 deletions
+89 -2
View File
@@ -267,9 +267,22 @@ function loadBackground(options) {
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
getLastFocused: (cb) => cb(opts.lastFocused || null),
create: (options2, cb) => {
created.push(options2);
// A copy, as the browser takes it at the call: the background
// reuses the object when it asks a second time.
created.push({ ...options2 });
// A browser that refuses any position it is given, as Chrome
// does for one it judges too far off screen.
if (opts.refusePosition && options2.left !== undefined) {
global.chrome.runtime.lastError = {
message:
"Invalid value for bounds. Bounds must be at least 50% within visible screen space.",
};
cb(undefined);
global.chrome.runtime.lastError = null;
return;
}
// A browser that answers with no window at all. The approval
// then has no window it can ever be answered in.
cb(opts.noWindow ? undefined : { id: created.length });
@@ -2716,3 +2729,77 @@ describe("removing a site in Settings disconnects it", () => {
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
});
});
// An approval window still open is often the last focused window, and headless
// Chrome reports one as 1280x720. Centred on that, the next approval window
// lands where the browser refuses to create it, and its request failed with no
// window at all (https://git.eeqj.de/sneak/AutistMask/issues/290).
describe("where an approval window opens", () => {
test("centred on the browser window the user was last in", async () => {
const bg = loadBackground({
lastFocused: {
type: "normal",
left: 0,
top: 0,
width: 1280,
height: 720,
},
});
bg.requestSign();
await settle();
expect(bg.created).toHaveLength(1);
expect(bg.created[0]).toMatchObject({ left: 460, top: 60 });
});
test("not centred on an approval window the user was last in", async () => {
const bg = loadBackground({
lastFocused: {
type: "popup",
left: 440,
top: 0,
width: 1280,
height: 720,
},
});
bg.requestSign();
await settle();
// Centred, it would be at left 900, the position the browser refused.
expect(bg.created).toHaveLength(1);
expect(bg.created[0].left).toBeUndefined();
expect(bg.created[0].top).toBeUndefined();
});
test("placed by the browser when it refuses the centred position", async () => {
const bg = loadBackground({
refusePosition: true,
lastFocused: {
type: "normal",
left: 1500,
top: 900,
width: 400,
height: 300,
},
});
const sign = bg.requestSign();
await settle();
expect(bg.created).toHaveLength(2);
expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 });
expect(bg.created[1].left).toBeUndefined();
expect(bg.created[1].top).toBeUndefined();
// The request waits on the second window rather than failing:
// closing that window is refusing the prompt.
expect(sign.result()).toBeNull();
bg.closeWindow(2);
await settle();
expect(sign.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
});
+82 -1
View File
@@ -2676,7 +2676,9 @@ function dappMessages(page, type) {
// The approval window the background opened. Approvals are raised from an
// RPC call rather than from a user gesture, so the extension opens a real
// popup window for them; it is an ordinary page in this context.
// popup window for them; it is an ordinary page in this context. It is the
// first one found: no test starts with an approval window open (see main()),
// so within a test it can only be this test's.
async function waitForApprovalWindow(ctx, timeout = 30000) {
const deadline = Date.now() + timeout;
for (;;) {
@@ -3633,6 +3635,55 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
);
});
// The extension used to centre every approval window on the last focused
// window. Centred on another approval window, the new one landed where the
// browser refused to create it, and the request failed with no window at all
// (https://git.eeqj.de/sneak/AutistMask/issues/290).
test("a prompt raised while another approval window has focus opens its own (#290)", async (env) => {
await startRequest(env.dapp, "focus-sign", "personal_sign", [
SIGN_HEX,
env.expectedAddress,
]);
const signWindow = await waitForApprovalWindow(env.ctx);
await visible(signWindow, "#view-approve-sign");
await signWindow.bringToFront();
const focused = await env.page.evaluate(
() =>
new Promise((resolve) => {
chrome.windows.getLastFocused((w) => resolve(w.type));
}),
);
assert(
focused === "popup",
"the sign window does not have focus, so this proves nothing: the " +
"last focused window is a " +
focused,
);
const opened = env.ctx.waitForEvent("page");
await startRequest(env.dapp, "focus-tx", "eth_sendTransaction", [
{
from: env.expectedAddress,
to: STUB_COUNTERPARTY,
value: toQuantity(TX_VALUE_WEI),
data: TX_DATA,
},
]);
const txWindow = await opened.catch(async () => {
const outcome = await settleRequest(env.dapp, "focus-tx", 1000);
throw new Error(
"the extension opened no window for the transaction: " +
JSON.stringify(outcome),
);
});
await visible(txWindow, "#view-approve-tx");
// Closing a window is refusing its prompt, so both answer 4001.
await closeApprovalPages(env.ctx);
await assertUserRejection(env.dapp, "focus-tx", "the transaction prompt");
await assertUserRejection(env.dapp, "focus-sign", "the sign prompt");
});
// The closing pass over both boundaries at once. Every message the section
// put on either channel is re-read here and required to be free of the
// password — and required to be there at all, method by method, so the
@@ -4002,6 +4053,36 @@ async function main() {
failure = e.message;
}
// No test starts with an approval window open. One that closes itself,
// after a signature or on Reject, can still be closing when the next
// test raises its prompt, and waitForApprovalWindow() would take it
// for the new one. After a test that passed, its windows get five
// seconds to close, and one still open then fails the test: it should
// have closed itself, and closing it here unreported would hide that.
// After a test that already failed, they are closed unreported, so
// that a prompt it left unanswered does not refuse the next one from
// its site.
if (!failure) {
const deadline = Date.now() + 5000;
let open;
for (;;) {
open = env.ctx
.pages()
.filter(
(p) => !p.isClosed() && p.url().includes("?approval="),
);
if (open.length === 0 || Date.now() > deadline) break;
await sleep(50);
}
if (open.length > 0) {
failure =
"an approval window was still open 5000ms after the " +
"test: " +
open.map((p) => p.url()).join(", ");
}
}
await closeApprovalPages(env.ctx);
// 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.