Compare commits

..
Author SHA1 Message Date
sneak 8b68b3e681 fix: only the user switches the wallet's network (closes #408)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A connected site's wallet_switchEthereumChain request for the other
supported network now opens a prompt in its own window, through the
existing approval machinery, naming the site and both networks. The
network, endpoints, balances and caches change, and chainChanged is
sent, only when the user approves it; rejecting or closing the prompt
answers 4001. One such prompt per site at a time; a request for the
active network needs none. The approval window no longer shows the
connection prompt while it waits for the approval's description,
since both prompts answer on the same port.

Model: opus-5-5
2026-10-07 21:33:11 +00:00
19 changed files with 48 additions and 418 deletions
+13 -25
View File
@@ -422,12 +422,11 @@ captured at `eth_sendRawTransaction` rather than against anything the extension
reported, rejecting each prompt is required to return a rejection to the page reported, rejecting each prompt is required to return a rejection to the page
rather than hang or resolve, a network switch request is required to leave the rather than hang or resolve, a network switch request is required to leave the
stored network unchanged and send no `chainChanged` until it is approved, a stored network unchanged and send no `chainChanged` until it is approved, a
network switch in Settings is required to send the page `chainChanged` with the prompt raised while another approval window has focus is required to open a
new chain id, a prompt raised while another approval window has focus is window of its own, and the password is required to be absent from every message
required to open a window of its own, and the password is required to be absent the approval window sends to the background — with the message that would carry
from every message the approval window sends to the background — with the it required to be present, so that check cannot pass by observing nothing. That
message that would carry it required to be present, so that check cannot pass by last one is the standing floor under
observing nothing. That last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157). [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
The limits of that coverage and of the rest of the Chrome suite, none of them The limits of that coverage and of the rest of the Chrome suite, none of them
@@ -1786,12 +1785,10 @@ view would leave a wallet one click from deletion.
plus a "+ Add token" button plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox, "UTC - Display: "Show tracked tokens with zero balance" checkbox, "UTC
Timestamps" checkbox, and a Theme selector (System / Light / Dark) Timestamps" checkbox, and a Theme selector (System / Light / Dark)
- Network: network selector (Ethereum Mainnet / Sepolia Testnet). The - Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
resets the RPC and Blockscout endpoints to that network's defaults. The
network changes only here, or when the user approves a site's request on network changes only here, or when the user approves a site's request on
**NetworkApproval**; either way, switching restores the RPC and Blockscout **NetworkApproval**
endpoints last used on that network, or that network's defaults if it has
none, and sends `chainChanged` with the new chain id to every open tab.
Choosing the network already active changes nothing and sends nothing
- Ethereum RPC: endpoint URL input + "Save" button (validated against - Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved) `eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against - Blockscout API: endpoint URL input + "Save" button (validated against
@@ -2124,12 +2121,7 @@ view would leave a wallet one click from deletion.
blocklist) blocklist)
- Site origin (bold, scheme and port included) + "wants you to sign a - Site origin (bold, scheme and port included) + "wants you to sign a
message" message"
- Warning box, shown for `eth_sign`: "This site used the older eth_sign - Danger warning box (shown for `eth_sign`, which signs a raw hash)
method. The data will be signed as a personal message." An `eth_sign`
request names the address first and the data second. Its data is signed
exactly as a `personal_sign` message is, as an EIP-191 personal message,
which is what the Ethereum JSON-RPC specification defines `eth_sign` to
return.
- Type: "Personal message" or "Typed data (EIP-712)" - Type: "Personal message" or "Typed data (EIP-712)"
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes - Message: for `personal_sign` and `eth_sign`, the text the message's bytes
@@ -2205,9 +2197,8 @@ view would leave a wallet one click from deletion.
- "Switch" / "Reject" buttons - "Switch" / "Reject" buttons
- **Transitions**: - **Transitions**:
- "Switch" → closes popup; the background switches the network as - "Switch" → closes popup; the background switches the network as
**Settings** does, restoring the RPC and Blockscout endpoints last used on **Settings** does, sends `chainChanged` to every open tab, and answers the
that network (or that network's defaults if it has none), sends site with success
`chainChanged` to every open tab, and answers the site with success
- "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and - "Reject" → closes popup; the site is answered with EIP-1193 code 4001 and
nothing changes nothing changes
- Popup window closed without answering → the same as "Reject" - Popup window closed without answering → the same as "Reject"
@@ -2700,7 +2691,7 @@ Currently supported:
### Non-Goals for 1.0 ### Non-Goals for 1.0
- Chains other than Ethereum mainnet and the Sepolia testnet - Multi-chain support (Ethereum mainnet only)
- Hardware wallet support - Hardware wallet support
## TODO ## TODO
@@ -2734,10 +2725,7 @@ Currently supported:
## Policies ## Policies
- We don't mention "the other wallet" by name in code or documentation. We're - We don't mention "the other wallet" by name in code or documentation. We're
our own thing. Written exception: the injected provider in our own thing.
`src/content/inpage.js` sets the flag named after the other wallet to `true`.
It is an interface-compatibility flag many dApps check, and without it the
wallet stops working on their sites.
- The README is the complete authoritative technical documentation. It's ok if - The README is the complete authoritative technical documentation. It's ok if
it gets big. it gets big.
+1 -4
View File
@@ -118,7 +118,4 @@ contradicts either, the originals govern.
- [ ] "Address" not "account" or "derived key" - [ ] "Address" not "account" or "derived key"
- [ ] "Password" not "encryption key" or "vault passphrase" - [ ] "Password" not "encryption key" or "vault passphrase"
- [ ] Error messages are full sentences - [ ] Error messages are full sentences
- [ ] No competitor mentioned by name in code or documentation. Written - [ ] No competitor mentioned by name in code or documentation
exception: the injected provider in `src/content/inpage.js` sets the flag
named after the other wallet to `true`, an interface-compatibility flag
many dApps check (README.md, Policies)
+5 -34
View File
@@ -2,9 +2,10 @@
- `git pull` `next` and cut a branch from it — one branch per issue, named - `git pull` `next` and cut a branch from it — one branch per issue, named
`issue-<N>-<slug>`. Never branch from `main`. `issue-<N>-<slug>`. Never branch from `main`.
- Do the work as one commit whose title ends with ` (closes #N)`. - Do the work as one commit whose title ends with ` (closes #N)`, with the
- Add no entry to Completed Steps. What landed is recorded on the tracker and in `TODO.md` update in that same commit.
`git log`, where each commit title names the issue it closes. - Move Next Step to the top of Completed Steps; move the top item of Future
Steps into Next Step.
- Run `make fmt`, then `make check`. A feature branch may be red; `next` and - Run `make fmt`, then `make check`. A feature branch may be red; `next` and
`main` may not. `main` may not.
- Rebase onto current `next` immediately before pushing — other branches land on - Rebase onto current `next` immediately before pushing — other branches land on
@@ -43,25 +44,6 @@ then continue tagging as milestones land.
# Completed Steps # Completed Steps
- 2026-10-08: The browser suites no longer read a screen before the page has
shown it ([#502](https://git.eeqj.de/sneak/AutistMask/issues/502)). Their wait
for a screen used to pass as soon as the element laid out, which every view
does until the page's stylesheet has applied, so an approval test could read
the prompt's fields before the page's script had filled them. `visible()` in
`tests/e2e/harness.js` and `waitVisible()` in `tests/e2e/firefox/driver.js`
now also wait for the page to finish loading and for neither the element nor
anything around it to carry the `hidden` class that `showView()` puts on every
view but the current one. No caller changed.
- 2026-10-08: Switching the network in Settings now tells open pages
([#500](https://git.eeqj.de/sneak/AutistMask/issues/500)). Once the switch is
saved, Settings asks the background to send `chainChanged` with the new chain
id to every open tab, through the same function an approved site request uses.
Choosing the network already active changes nothing and sends nothing. Only
the extension's own pages can ask for this. `tests/chainSwitchGate.test.js`
drives the real Settings view against the background, and the Chrome suite
checks that the test page hears both switches.
- 2026-10-07: A site can no longer switch the wallet's network by itself - 2026-10-07: A site can no longer switch the wallet's network by itself
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)). A connected site's ([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)). A connected site's
`wallet_switchEthereumChain` request for the other supported network opens a `wallet_switchEthereumChain` request for the other supported network opens a
@@ -72,20 +54,9 @@ then continue tagging as milestones land.
or closing the prompt answers 4001. One such prompt per site at a time. The or closing the prompt answers 4001. One such prompt per site at a time. The
approval window no longer shows the connection prompt while it waits for the approval window no longer shows the connection prompt while it waits for the
background to describe the approval, because that prompt's "Allow" answers on background to describe the approval, because that prompt's "Allow" answers on
the same port as the new one; `tests/approvalWindow.test.js` checks that it the same port as the new one. `tests/chainSwitchGate.test.js` and both browser
shows no screen until then. `tests/chainSwitchGate.test.js` and both browser
suites drive the prompt. suites drive the prompt.
- 2026-10-07: Two contradictions between the documents and the code are resolved
as ruled on [#165](https://git.eeqj.de/sneak/AutistMask/issues/165). The
README's 1.0 non-goal now puts Ethereum mainnet and the Sepolia testnet in
scope and other chains out of it. The injected provider's flag named after the
other wallet stays, as an interface-compatibility flag many dApps check, and
is written down as an exception to the rule against naming competitors in the
README's Policies section, in `RULES.md` and in a comment beside it in
`src/content/inpage.js`. `script/check-censored` already allows that flag only
in that file and its built copies, so it is unchanged.
- 2026-10-07: Two holes in what `tests/persistedFieldContract.test.js` checks - 2026-10-07: Two holes in what `tests/persistedFieldContract.test.js` checks
are closed ([#379](https://git.eeqj.de/sneak/AutistMask/issues/379)). The are closed ([#379](https://git.eeqj.de/sneak/AutistMask/issues/379)). The
check that every swept field is driven both truthy and falsy counts only check that every swept field is driven both truthy and falsy counts only
+3 -10
View File
@@ -984,8 +984,9 @@ async function handleRpc(method, params, origin) {
if (method === "eth_sign") { if (method === "eth_sign") {
signParams.dangerWarning = signParams.dangerWarning =
"This site used the older eth_sign method. " + "\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
"The data will be signed as a personal message."; "This can be used to sign transactions that drain your funds. " +
"Only proceed if you fully understand what you are signing.";
} }
if (findPendingApproval(origin, "sign")) { if (findPendingApproval(origin, "sign")) {
@@ -1470,7 +1471,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
"AUTISTMASK_ADDRESSES_REMOVED", "AUTISTMASK_ADDRESSES_REMOVED",
"AUTISTMASK_GET_CONNECTED_SITES", "AUTISTMASK_GET_CONNECTED_SITES",
"AUTISTMASK_REMOVE_SITE", "AUTISTMASK_REMOVE_SITE",
"AUTISTMASK_NETWORK_CHANGED",
]; ];
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) { if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
sendResponse({ error: "Unauthorized sender" }); sendResponse({ error: "Unauthorized sender" });
@@ -1854,13 +1854,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
broadcastSiteRemoved(msg.origin); broadcastSiteRemoved(msg.origin);
return false; return false;
} }
// Settings switched the network and has saved it. Open tabs are told the
// new chain id the same way as after a site's approved switch request.
if (msg.type === "AUTISTMASK_NETWORK_CHANGED") {
broadcastChainChanged(msg.chainId);
return false;
}
}); });
module.exports = { PROXY_METHODS }; module.exports = { PROXY_METHODS };
+1 -4
View File
@@ -99,10 +99,7 @@
const provider = { const provider = {
isAutistMask: true, isAutistMask: true,
// An interface-compatibility flag many dApps check. Kept as a written isMetaMask: true, // compatibility — many dApps check this
// exception to the rule that no competitor is named in code: see
// Policies in README.md, and RULES.md.
isMetaMask: true,
chainId: currentChainId, chainId: currentChainId,
networkVersion: currentNetworkVersion, networkVersion: currentNetworkVersion,
selectedAddress: null, selectedAddress: null,
+8 -12
View File
@@ -7,10 +7,9 @@
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<!-- Chrome gives extension pages a stylesheet of its own that sets the <!-- Chrome gives extension pages a stylesheet of its own that sets the
font and its size on body, and a Tailwind class beats it only when font on body, and a Tailwind class beats it only when marked
marked important: hence font-mono! and text-sm! rather than important: hence font-mono! rather than font-mono. -->
font-mono and text-sm. --> <body class="bg-bg text-fg font-mono! text-sm">
<body class="bg-bg text-fg font-mono! text-sm!">
<div id="app" class="p-2 pr-5 overflow-x-hidden"> <div id="app" class="p-2 pr-5 overflow-x-hidden">
<!-- ============ GLOBAL TITLE BAR ============ --> <!-- ============ GLOBAL TITLE BAR ============ -->
<div <div
@@ -88,14 +87,11 @@
<!-- Mnemonic form section --> <!-- Mnemonic form section -->
<div id="add-wallet-section-mnemonic"> <div id="add-wallet-section-mnemonic">
<!-- The die button shares the instruction's row: on a row <p class="mb-2">
of its own it pushes Import below the fold at Enter your 12 or 24 word recovery phrase below, or click
360x600. --> the button to roll the die for a new one.
<div class="mb-2 flex justify-between items-start gap-2"> </p>
<p> <div class="mb-1 flex justify-end">
Enter your 12 or 24 word recovery phrase below, or
click the button to roll the die for a new one.
</p>
<button <button
id="btn-generate-phrase" id="btn-generate-phrase"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
+4 -4
View File
@@ -329,9 +329,9 @@ function showTxApproval(details) {
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null; const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd); const usdStr = formatUsd(ethUsd);
// In the native currency of the network the transaction is for, which the // In the native currency of the network the transaction is for, which the
// Network line names, not the active network's: the active network can // Network line names, not the active network's: a site can switch the
// change, in Settings or when the user approves a site's request, after // active network after this transaction is prepared and back before it is
// this transaction is prepared and change back before it is signed. // signed.
$("approve-tx-value").textContent = $("approve-tx-value").textContent =
ethValueFormatted + ethValueFormatted +
" " + " " +
@@ -721,7 +721,7 @@ function showSignApproval(details) {
"This message is plain text, not hex, so it cannot be signed."; "This message is plain text, not hex, so it cannot be signed.";
} }
// The warning the background attaches to an eth_sign request. // Display danger warning for eth_sign (raw hash signing)
const warningEl = $("approve-sign-danger-warning"); const warningEl = $("approve-sign-danger-warning");
if (warningEl) { if (warningEl) {
if (sp.dangerWarning) { if (sp.dangerWarning) {
-4
View File
@@ -316,11 +316,7 @@ function init(ctx) {
const networkSelect = $("settings-network"); const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => { networkSelect.addEventListener("change", async () => {
const newId = networkSelect.value; const newId = networkSelect.value;
if (newId === state.networkId) return;
const net = await onChainSwitch(newId); const net = await onChainSwitch(newId);
// Open pages are told by the background, as after a site's approved
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
+2 -3
View File
@@ -84,9 +84,8 @@ function show(tx) {
contractAddress: tx.contractAddress || null, contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and // The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's: // the fee are in its native currency, not the active network's:
// the active network can change, in Settings or when the user // a site can switch the active network before a later popup
// approves a site's request, before a later popup shows this // shows this screen again.
// screen again.
chainId: tx.chainId, chainId: tx.chainId,
}, },
}; };
+2 -3
View File
@@ -89,9 +89,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// A native amount, here and on the success and error screens, is in the // A native amount, here and on the success and error screens, is in the
// native currency of txInfo.chainId, the network the transaction was sent // native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: the active network can change, in // on, not the active network's: a site can switch the active network
// Settings or when the user approves a site's request, while this screen // while this screen is open or before a later popup resumes it.
// is open or before a later popup resumes it.
const symbol = const symbol =
txInfo.token === "ETH" txInfo.token === "ETH"
? nativeCurrencyByChainId(txInfo.chainId) ? nativeCurrencyByChainId(txInfo.chainId)
-47
View File
@@ -1,47 +0,0 @@
// The approval window shows no screen until the background has described the
// approval it answers (https://git.eeqj.de/sneak/AutistMask/issues/408). The
// connection prompt's "Allow" and the network switch prompt's "Switch" answer
// on the same port, so a window that showed the connection prompt while it
// waited could approve a network switch.
//
// Booted through the real popup entry point, which is where the connection
// prompt used to be put up before the background had answered.
const {
bootPopup,
cleanupPopup,
settle,
unversionedValidProfile,
} = require("./support/popupBoot");
afterEach(cleanupPopup);
test("the approval window shows no screen until the background describes the approval", async () => {
// The background's answer, held until the test gives it.
let answer = null;
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") answer = reply;
},
},
});
expect(answer).not.toBeNull();
// No screen at all, the connection prompt included.
expect(env.visibleViews()).toEqual([]);
answer({
type: "network",
origin: "https://dapp.example",
currentNetworkId: "mainnet",
requestedNetworkId: "sepolia",
isPhishingDomain: false,
});
await settle();
expect(env.visibleViews()).toEqual(["approve-network"]);
expect(env.pageErrors).toEqual([]);
});
-47
View File
@@ -1551,53 +1551,6 @@ describe("what the approval is verified against", () => {
}); });
}); });
// eth_sign names the address first and the data second, the reverse of
// personal_sign, and is answered as the JSON-RPC specification defines it:
// with an EIP-191 personal message signature over the data, the same
// signature personal_sign returns
// (https://git.eeqj.de/sneak/AutistMask/issues/409).
describe("eth_sign", () => {
test("is read address first, and returns a personal message signature over the data", async () => {
const bg = loadBackground();
const page = bg.send(
{
type: "AUTISTMASK_RPC",
method: "eth_sign",
params: [signer.address, MESSAGE],
},
{ origin: ORIGIN },
);
await settle();
const id = approvalIdIn(bg.created.map((c) => c.url));
const details = bg.send(
{ type: "AUTISTMASK_GET_APPROVAL", id },
{ url: bg.fromPopup.url },
);
const { signParams } = details.sendResponse.mock.calls[0][0];
expect(signParams).toMatchObject({
method: "eth_sign",
message: MESSAGE,
from: signer.address,
});
expect(signParams.dangerWarning).toBe(
"This site used the older eth_sign method. " +
"The data will be signed as a personal message.",
);
const signature = await signer.signMessage(
Buffer.from(MESSAGE.slice(2), "hex"),
);
bg.send(
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: true, signature },
{ url: bg.fromPopup.url },
);
await settle();
expect(page.sendResponse).toHaveBeenCalledWith({ result: signature });
});
});
// The interlock must not cost the retry the approval exists to allow. // The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => { describe("the interlock releases a failed attempt", () => {
test("a retryable failure before the broadcast leaves the approval usable", async () => { test("a retryable failure before the broadcast leaves the approval usable", async () => {
+1 -117
View File
@@ -16,9 +16,7 @@
// //
// The endpoint half of #308 lives in tests/networkEndpoints.test.js, which // The endpoint half of #308 lives in tests/networkEndpoints.test.js, which
// covers the popup's chain switch; this file covers the background's, which // covers the popup's chain switch; this file covers the background's, which
// goes through storage rather than the shared state singleton. The last block // goes through storage rather than the shared state singleton.
// covers what the background tells open tabs when the user switches the
// network in Settings.
const { networkById } = require("../src/shared/networks"); const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
@@ -227,14 +225,6 @@ function loadBackground() {
answerPrompt, answerPrompt,
closePrompt, closePrompt,
opened, opened,
// A message to the background from `sender`, and its answer.
send: (msg, sender) => {
let reply = null;
messageListener(msg, sender, (r) => {
reply = r;
});
return reply;
},
walletState: () => storage.read("autistmask"), walletState: () => storage.read("autistmask"),
chainChangedEvents: () => chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"), toTabs.filter((m) => m.eventName === "chainChanged"),
@@ -400,109 +390,3 @@ describe("only the user switches the network", () => {
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC); expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
}); });
}); });
// Switching the network in Settings tells open pages, as an approved site
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). These drive
// the real Settings view over the background's storage, with what it sends
// delivered to the background from the extension's own page.
describe("switching the network in Settings tells every open tab", () => {
const POPUP = { url: EXT_URL + "src/popup/index.html" };
// A stand-in for one DOM node: enough of an element for init() to set
// properties on it and hang listeners off it.
function fakeElement() {
return {
value: "",
checked: false,
textContent: "",
style: {},
dataset: {},
classList: { add() {}, remove() {} },
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
querySelectorAll: () => [],
};
}
// Settings, opened the way the popup opens it. Returns its network
// selector.
async function openSettings(bg) {
const elements = {};
const element = (id) => (elements[id] ||= fakeElement());
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
updateDebugBanner: () => {},
showFlash: () => {},
escapeHtml: (s) => s,
flashCopyFeedback: () => {},
goBack: () => {},
pushCurrentView: () => {},
onViewLeave: () => {},
VIEWS: [],
}));
global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, POPUP);
};
await require("../src/shared/state").loadState();
require("../src/popup/views/settings").init({
pageClosed: new AbortController().signal,
});
const select = element("settings-network");
select.value = "mainnet";
return select;
}
// The user picks `networkId` in the selector.
async function choose(select, networkId) {
select.value = networkId;
await select.listeners.change();
await settle();
}
afterEach(() => {
jest.dontMock("../src/popup/views/helpers");
});
test("switching to the other network sends chainChanged once, with its chain id", async () => {
const bg = loadBackground();
const select = await openSettings(bg);
await choose(select, "sepolia");
expect(bg.walletState().networkId).toBe("sepolia");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: SEPOLIA.chainId,
},
]);
});
test("choosing the network already active changes nothing and sends nothing", async () => {
const bg = loadBackground();
const select = await openSettings(bg);
const before = bg.walletState();
await choose(select, "mainnet");
expect(bg.walletState()).toEqual(before);
expect(bg.chainChangedEvents()).toEqual([]);
});
test("a page cannot make the background send chainChanged", async () => {
const bg = loadBackground();
const reply = bg.send(
{ type: "AUTISTMASK_NETWORK_CHANGED", chainId: SEPOLIA.chainId },
{ url: CONNECTED_ORIGIN + "/" },
);
await settle();
expect(reply).toEqual({ error: "Unauthorized sender" });
expect(bg.chainChangedEvents()).toEqual([]);
});
});
+2 -8
View File
@@ -279,18 +279,12 @@ class Driver {
// Shown means shown: in the popup a view is switched by toggling a // Shown means shown: in the popup a view is switched by toggling a
// "hidden" class, and an element that is present but collapsed is not // "hidden" class, and an element that is present but collapsed is not
// the thing a test means by visible. Until the page's stylesheet has // the thing a test means by visible.
// applied that class hides nothing and every view lays out, so the page
// must also have finished loading, which it does only after its
// stylesheet, and neither the element nor anything enclosing it may
// carry the class (https://git.eeqj.de/sneak/AutistMask/issues/502).
async waitVisible(selector, timeout = DEFAULT_WAIT_MS) { async waitVisible(selector, timeout = DEFAULT_WAIT_MS) {
return this.waitFor( return this.waitFor(
"selector " + selector + " to be visible", "selector " + selector + " to be visible",
`const el = document.querySelector(arguments[0]); `const el = document.querySelector(arguments[0]);
if (document.readyState !== "complete" || !el) return false; if (!el) return false;
if (el.closest(".hidden")) return false;
if (getComputedStyle(el).visibility !== "visible") return false;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;`, return r.width > 0 && r.height > 0;`,
[selector], [selector],
-12
View File
@@ -128,18 +128,6 @@ step("the popup is drawn in the monospace font it declares", async (env) => {
); );
}); });
// The same check as the Chrome suite's (#456), so both browsers are held to
// the same size.
step("the popup's text is drawn at the 14px text-sm declares", async (env) => {
const size = await env.driver.execute(
"return getComputedStyle(document.body).fontSize;",
);
assert(
size === "14px",
"the popup's text is drawn at " + size + ", not at the 14px of text-sm",
);
});
// The recovery screen (#361): the Chrome suite's four cases, run before any // The recovery screen (#361): the Chrome suite's four cases, run before any
// wallet exists for the same reason. With no wallet nothing saves on a timer, // wallet exists for the same reason. With no wallet nothing saves on a timer,
// so no save can write a good record over the unreadable one. The last of them // so no save can write a good record over the unreadable one. The last of them
+1 -26
View File
@@ -333,33 +333,8 @@ async function launch(routeOpts) {
const PASSWORD = "e2e-harness-password"; const PASSWORD = "e2e-harness-password";
// Waits until the page shows `selector`, not only until it lays out. Until the
// page's stylesheet has applied, the `hidden` class that showView() keeps on
// every view but the current one hides nothing and every view lays out, so a
// test could read a screen before the page's script had filled it
// (https://git.eeqj.de/sneak/AutistMask/issues/502). So the page must also
// have finished loading, which it does only after its stylesheet, and neither
// the element nor anything enclosing it may carry `hidden`. Polled on a timer:
// animation frames are not guaranteed to a window that is not in front.
async function visible(page, selector, timeout = 15000) { async function visible(page, selector, timeout = 15000) {
await page await page.waitForSelector(selector, { state: "visible", timeout });
.waitForFunction(
(sel) => {
const el = document.querySelector(sel);
if (document.readyState !== "complete" || !el) return false;
if (el.closest(".hidden")) return false;
if (getComputedStyle(el).visibility !== "visible") return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
},
selector,
{ polling: 50, timeout },
)
.catch((e) => {
throw new Error(
"the page did not show " + selector + ": " + e.message,
);
});
} }
// An empty WebAssembly module: magic number and version header, no // An empty WebAssembly module: magic number and version header, no
-48
View File
@@ -113,18 +113,6 @@ test("the popup is drawn in the monospace font it declares (#418)", async (env)
); );
}); });
// The same stylesheet sets the size on body to 75%, which would draw the
// popup at 12px.
test("the popup's text is drawn at the 14px text-sm declares (#456)", async (env) => {
const size = await env.page.evaluate(
() => getComputedStyle(document.body).fontSize,
);
assert(
size === "14px",
"the popup's text is drawn at " + size + ", not at the 14px of text-sm",
);
});
// The empirical half of #182. The manifest change is only a claim about // 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 // 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), // together, and the run covers both: the popup realm compiles WASM (here),
@@ -4550,42 +4538,6 @@ test("a site's network switch changes nothing until the user approves it (#408)"
); );
}); });
// Switching the network in Settings tells the page too, as an approved site
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). The wallet
// goes back to mainnet the same way at the end.
test("a network switch in Settings tells the page (#500)", async (env) => {
const { mainnet, sepolia } = NETWORKS;
const before = await storedNetwork(env.page);
assert(
before.networkId === "mainnet",
"this test starts on mainnet, not on " + before.networkId,
);
const eventsBefore = (await chainChangedEvents(env.dapp)).length;
await openSettings(env.page);
await env.page.selectOption("#settings-network", "sepolia");
let events = await chainChangedEvents(env.dapp, eventsBefore + 1);
assert(
events.length === eventsBefore + 1 &&
events[events.length - 1].data === sepolia.chainId,
"the page was not told of the switch to Sepolia: " +
JSON.stringify(events),
);
await env.page.selectOption("#settings-network", "mainnet");
events = await chainChangedEvents(env.dapp, eventsBefore + 2);
assert(
events.length === eventsBefore + 2 &&
events[events.length - 1].data === mainnet.chainId,
"the page was not told of the switch back to mainnet: " +
JSON.stringify(events),
);
assert(
isDeepStrictEqual(await storedNetwork(env.page), before),
"switching back did not restore the mainnet network and endpoints",
);
});
// The closing pass over both boundaries at once. Every message the section // 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 // 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 // password — and required to be there at all, method by method, so the
+4 -4
View File
@@ -345,10 +345,10 @@ describe.each([
}); });
// A transaction's value and fee are in the native currency of the network the // A transaction's value and fee are in the native currency of the network the
// transaction is on, which need not be the active one. The active network can // transaction is on, which need not be the active one. A site can switch the
// change, in Settings or when the user approves a site's request, after a // active network after its transaction is prepared and back before it is
// transaction is prepared and change back before it is signed, and a popup // signed, and a popup opened after a switch shows a sent or listed transaction
// opened after a switch shows a sent or listed transaction again. The wallet's balances follow the active network; these do not. // again. The wallet's balances follow the active network; these do not.
describe.each([ describe.each([
["mainnet", "sepolia", "ETH"], ["mainnet", "sepolia", "ETH"],
["sepolia", "mainnet", "SepoliaETH"], ["sepolia", "mainnet", "SepoliaETH"],
+1 -6
View File
@@ -238,10 +238,6 @@ async function settle() {
* @param {object} [options] * @param {object} [options]
* @param {object} [options.storage] a storage stub from makeStorageStub(), for * @param {object} [options.storage] a storage stub from makeStorageStub(), for
* a test that needs to make writes fail or to watch the round trips. * a test that needs to make writes fail or to watch the round trips.
* @param {string} [options.search] the page URL's query string, such as
* "?approval=" and an id for the popup opened as an approval window.
* @param {object} [options.runtime] members of chrome.runtime that replace the
* stub's own, for a test that has to answer the background's messages.
* @returns {Promise<object>} handles onto the booted page. * @returns {Promise<object>} handles onto the booted page.
*/ */
async function bootPopup(stored, options) { async function bootPopup(stored, options) {
@@ -285,13 +281,12 @@ async function bootPopup(stored, options) {
sendMessage: jest.fn(async () => ({})), sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p, getURL: (p) => "chrome-extension://autistmask/" + p,
onMessage: { addListener: () => {} }, onMessage: { addListener: () => {} },
...(options && options.runtime),
}, },
}; };
globalThis.document = document; globalThis.document = document;
globalThis.window = { globalThis.window = {
location: { location: {
search: (options && options.search) || "", search: "",
href: "chrome-extension://autistmask/src/popup/index.html", href: "chrome-extension://autistmask/src/popup/index.html",
reload: () => reloads.push(Date.now()), reload: () => reloads.push(Date.now()),
}, },