Compare commits

..
2 Commits
Author SHA1 Message Date
sneak effc452f3b fix: keep the flash line to its one line at any message length (closes #252)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
The flash line reserves one line, so a message that wrapped pushed the
screen below it down. #flash-msg no longer wraps: text too long for it is
cut with an ellipsis, and showFlash() puts the whole message in its title.
Every message is also reworded to at most 50 characters so none is cut,
and the add-token screens flash a fixed line for any error other than the
two lookup messages, logging the detail.

A new end-to-end test writes a message several lines long into the line
and fails if the line or the screen below it moves.

Model: opus-5-5
2026-10-04 06:27:51 +00:00
clawbot 49a7da87e8 harden: list and end site connections made without Remember in Settings (closes #406)
check / check (push) Successful in 2m25s
e2e / e2e-chrome (push) Successful in 3m15s
e2e / e2e-firefox (push) Successful in 2m21s
A site allowed without "Remember" lives only in the background's
in-memory connectedSites map. Settings never listed it, and
AUTISTMASK_REMOVE_SITE, sent on every remove, did nothing, so the user
could not end such a connection.

Settings now asks the background for those sites and lists them under
Connected Sites. Removing a site from Allowed Sites or Connected Sites
drops its remembered entry under every address and sends
AUTISTMASK_REMOVE_SITE with the hostname; the background deletes every
matching connectedSites entry and sends accountsChanged with an empty
list to the site's tabs. Only the extension's own pages may send either
message.

Model: opus-5-5
2026-10-04 06:41:39 +02:00
12 changed files with 522 additions and 122 deletions
+19 -5
View File
@@ -1582,8 +1582,14 @@ view would leave a wallet one click from deletion.
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation — a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
is refused with a flash message and the field snaps back to the stored is refused with a flash message and the field snaps back to the stored
threshold, so a number the user did not type is never stored. threshold, so a number the user did not type is never stored.
- Allowed Sites: list with remove buttons - Allowed Sites: the hostnames remembered as allowed, under any address,
- Denied Sites: list with remove buttons with remove buttons
- Connected Sites: the hostnames of the sites allowed without "Remember my
choice" that are still connected, with remove buttons. Only the background
holds these, in memory, and Settings asks it for them with
`AUTISTMASK_GET_CONNECTED_SITES`
- Denied Sites: the hostnames remembered as denied, under any address, with
remove buttons
- About: project link, license, author, version, release date, and the - About: project link, license, author, version, release date, and the
commit, which links to the commit in the repository commit, which links to the commit in the repository
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that - Debug: hidden until revealed, then an "Enable debug mode" checkbox that
@@ -1594,8 +1600,15 @@ view would leave a wallet one click from deletion.
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase** - `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
- `[x]` on a wallet → **DeleteWallet** - `[x]` on a wallet → **DeleteWallet**
- Tap wallet name → inline rename field (no screen change) - Tap wallet name → inline rename field (no screen change)
- `[x]` on a tracked token or a site → removes it in place (no screen - `[x]` on a tracked token → removes it in place (no screen change)
change) - `[x]` on an allowed or connected site → disconnects that site, in place:
its hostname is dropped from Allowed Sites under every address, and
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
without "Remember" from an origin with that hostname, under any address,
and send `accountsChanged` with an empty list to the site's open tabs.
Only the extension's own pages may send either message
- `[x]` on a denied site → forgets the refusal, in place; it connects
nothing and tells the background nothing
- Ten clicks on the version → reveals the Debug well (no screen change) - Ten clicks on the version → reveals the Debug well (no screen change)
- "Back" (or Settings gear again) → previous screen (Home) - "Back" (or Settings gear again) → previous screen (Home)
@@ -1790,7 +1803,8 @@ view would leave a wallet one click from deletion.
- **Transitions**: - **Transitions**:
- "Allow" / "Deny" → closes popup (returns result to background script; the - "Allow" / "Deny" → closes popup (returns result to background script; the
choice is persisted to the allowed or denied list when "Remember" is choice is persisted to the allowed or denied list when "Remember" is
checked) checked, and an "Allow" without it is listed under Connected Sites in
**Settings**)
- Popup closed without answering → treated as a denial - Popup closed without answering → treated as a denial
#### TxApproval (`approve-tx`) #### TxApproval (`approve-tx`)
+23 -13
View File
@@ -45,20 +45,30 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-10-04: Every flash message fits on the one line the flash line reserves, - 2026-10-04: The flash line keeps to the one line it reserves at any message
and a test measures it length ([#252](https://git.eeqj.de/sneak/AutistMask/issues/252)). A message
([#252](https://git.eeqj.de/sneak/AutistMask/issues/252)). A message that that wrapped pushed the whole screen below it down. `#flash-msg` no longer
wrapped pushed the whole screen below it down. Rather than reserve a second wraps: text too long for the line is cut with an ellipsis, and `showFlash()`
line on every screen or let the flash cover the screen, every message is now puts the whole message in the line's title. Every message is also reworded to
at most 50 characters, one line of the popup's monospace font: the longer ones at most 50 characters so none is cut; none carries a wallet name or text from
are reworded, and messages that carried a wallet name or text from a server no a server, and the add-token screens flash a fixed line for any error other
longer do, since neither has a length limit. The rule is written at than a contract that is not a token. A new test in `tests/e2e/run.js` puts a
`showFlash()` in `src/popup/views/helpers.js`. A new test in message several lines long on the line and fails if the line or the screen
`tests/e2e/run.js` drives the longest message and fails if the line's rendered below it moves. The two approval-screen error boxes are left to
height grows. It measures in the monospace font, because Chromium draws the
popup in its narrower system font and only Firefox shows the wrap. The two
approval-screen error boxes are left to
[#297](https://git.eeqj.de/sneak/AutistMask/issues/297). [#297](https://git.eeqj.de/sneak/AutistMask/issues/297).
- 2026-10-04: Settings lists the sites connected without "Remember", and
removing a site there disconnects it
([#406](https://git.eeqj.de/sneak/AutistMask/issues/406)). Such a connection
lives only in the background's in-memory `connectedSites` map, so Settings
never showed it and the user could not end it; `AUTISTMASK_REMOVE_SITE`, sent
on every remove, did nothing. Settings now asks the background for those sites
(`AUTISTMASK_GET_CONNECTED_SITES`) and lists them under Connected Sites.
Removing a site from Allowed Sites or Connected Sites drops its remembered
entry under every address and sends `AUTISTMASK_REMOVE_SITE` with the
hostname; the background deletes every `connectedSites` entry for that
hostname and sends `accountsChanged` with an empty list to its open tabs. Only
the extension's own pages may send either message. Removing a denied site no
longer sends it, since forgetting a refusal ends no connection.
- 2026-10-04: Removing an address or deleting a wallet ends every site - 2026-10-04: Removing an address or deleting a wallet ends every site
connection approved without "Remember" for the addresses removed connection approved without "Remember" for the addresses removed
([#245](https://git.eeqj.de/sneak/AutistMask/issues/245)). Such a connection ([#245](https://git.eeqj.de/sneak/AutistMask/issues/245)). Such a connection
+41 -1
View File
@@ -1110,6 +1110,24 @@ async function broadcastAccountsChanged() {
} }
} }
// Tell every open tab of a site Settings removed that it has no account.
async function broadcastSiteRemoved(hostname) {
let tabs;
try {
tabs = await tabsQuery({});
} catch {
return;
}
for (const tab of tabs) {
if (!tab.url || extractHostname(tab.url) !== hostname) continue;
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
}).catch(() => {});
}
}
// Background balance refresh: every 60 seconds when the popup isn't open. // Background balance refresh: every 60 seconds when the popup isn't open.
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh // When the popup IS open, its 10-second interval keeps lastBalanceRefresh
// fresh, so this naturally skips. // fresh, so this naturally skips.
@@ -1306,6 +1324,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
"AUTISTMASK_TX_RESPONSE", "AUTISTMASK_TX_RESPONSE",
"AUTISTMASK_SIGN_RESPONSE", "AUTISTMASK_SIGN_RESPONSE",
"AUTISTMASK_ADDRESSES_REMOVED", "AUTISTMASK_ADDRESSES_REMOVED",
"AUTISTMASK_GET_CONNECTED_SITES",
"AUTISTMASK_REMOVE_SITE",
]; ];
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" });
@@ -1662,8 +1682,28 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return false; return false;
} }
// Settings lists the sites connected without "Remember", which only this
// worker knows. The origin is what precedes the key's last colon.
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
sendResponse(
Object.keys(connectedSites).map((key) =>
extractHostname(key.slice(0, key.lastIndexOf(":"))),
),
);
return false;
}
// Settings removed this site and has already dropped its remembered
// entries. Its connections approved without "Remember" end here, under
// every address, and its open tabs are told it has no account.
if (msg.type === "AUTISTMASK_REMOVE_SITE") { if (msg.type === "AUTISTMASK_REMOVE_SITE") {
// Popup already saved state; nothing else needed for (const key of Object.keys(connectedSites)) {
const origin = key.slice(0, key.lastIndexOf(":"));
if (extractHostname(origin) === msg.hostname) {
delete connectedSites[key];
}
}
broadcastSiteRemoved(msg.hostname);
return false; return false;
} }
}); });
+10 -1
View File
@@ -33,7 +33,7 @@
<!-- ============ FLASH MESSAGE AREA ============ --> <!-- ============ FLASH MESSAGE AREA ============ -->
<div <div
id="flash-msg" id="flash-msg"
class="text-xs text-muted min-h-[1.25rem] mb-1" class="text-xs text-muted min-h-[1.25rem] mb-1 truncate"
></div> ></div>
<!-- ============ WELCOME / FIRST USE ============ --> <!-- ============ WELCOME / FIRST USE ============ -->
@@ -1064,6 +1064,15 @@
<div id="settings-allowed-sites"></div> <div id="settings-allowed-sites"></div>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Connected Sites</h3>
<p class="text-xs text-muted mb-2">
Sites you allowed without "Remember my choice".
Switching address disconnects them.
</p>
<div id="settings-connected-sites"></div>
</div>
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Denied Sites</h3> <h3 class="font-bold mb-1">Denied Sites</h3>
<p class="text-xs text-muted mb-2"> <p class="text-xs text-muted mb-2">
+9 -2
View File
@@ -69,8 +69,15 @@ function init(ctx) {
require("./addressDetail").show(); require("./addressDetail").show();
} catch (e) { } catch (e) {
const detail = e.shortMessage || e.message || String(e); const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", contractAddr, detail); log.errorf("Adding token failed for", contractAddr, detail);
showFlash(detail); // lookupTokenInfo() rejects a contract with a one-line message
// starting "Not a valid ERC-20 token". Any other error, such as a
// failed save, can be far longer, so it is only logged.
showFlash(
detail.startsWith("Not a valid ERC-20 token")
? detail
: "Could not add the token.",
);
infoEl.textContent = ""; infoEl.textContent = "";
infoEl.style.visibility = "hidden"; infoEl.style.visibility = "hidden";
} }
+8 -11
View File
@@ -223,22 +223,19 @@ function clearFlash() {
flashTimer = null; flashTimer = null;
} }
$("flash-msg").textContent = ""; $("flash-msg").textContent = "";
$("flash-msg").title = "";
} }
// The flash line reserves the height of exactly one line, so a message that // The flash line reserves exactly one line, and a message that wrapped would
// wraps pushes the whole screen below it down, which README's No Layout Shift // push the screen below it down (README, No Layout Shift). So #flash-msg never
// rule forbids. Every message must fit on one line of the popup's monospace // wraps: text too long for the line is cut with an ellipsis, and the whole
// font: 50 characters at most, and nothing of unbounded length, such as a // message is also put in the line's title. Write messages to fit, at most 50
// wallet name or text from a server, may be put into one. The longest message // characters, so none is cut.
// is measured by "the longest flash message fits on one line (#252)" in
// tests/e2e/run.js; point that test at any message longer than it.
function showFlash(msg, duration = 2000) { function showFlash(msg, duration = 2000) {
clearFlash(); clearFlash();
$("flash-msg").textContent = msg; $("flash-msg").textContent = msg;
flashTimer = setTimeout(() => { $("flash-msg").title = msg;
$("flash-msg").textContent = ""; flashTimer = setTimeout(clearFlash, duration);
flashTimer = null;
}, duration);
} }
// A stored token balance as a number, or null when there is no number in it. // A stored token balance as a number, or null when there is no number in it.
+51 -23
View File
@@ -29,46 +29,63 @@ const {
GITEA_COMMIT_URL, GITEA_COMMIT_URL,
} = require("../../shared/buildInfo"); } = require("../../shared/buildInfo");
const { notify } = require("../../shared/browserApi"); const { notify, sendMessage } = require("../../shared/browserApi");
let versionClickCount = 0; let versionClickCount = 0;
let versionClickTimer = null; let versionClickTimer = null;
function renderSiteList(containerId, siteMap, stateKey) { // One row per hostname, however many addresses or origins it appears under,
// each with an [x] that hands it to onRemove.
function renderSiteList(containerId, hostnames, onRemove) {
const container = $(containerId); const container = $(containerId);
const hostnames = [...new Set(Object.values(siteMap).flat())]; const unique = [...new Set(hostnames)];
if (hostnames.length === 0) { if (unique.length === 0) {
container.innerHTML = '<p class="text-xs text-muted">None</p>'; container.innerHTML = '<p class="text-xs text-muted">None</p>';
return; return;
} }
let html = ""; let html = "";
hostnames.forEach((hostname) => { unique.forEach((hostname) => {
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`; html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
// A hostname the URL parser produced cannot carry a delimiter, so // A hostname the URL parser produced cannot carry a delimiter, so
// this is escaped for the rule rather than for a known hole — the // this is escaped for the rule rather than for a known hole — the
// rule being that nothing reaches innerHTML unescaped. // rule being that nothing reaches innerHTML unescaped.
html += `<span>${escapeHtml(hostname)}</span>`; html += `<span>${escapeHtml(hostname)}</span>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${escapeHtml(stateKey)}" data-hostname="${escapeHtml(hostname)}">[x]</button>`; html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
html += `</div>`; html += `</div>`;
}); });
container.innerHTML = html; container.innerHTML = html;
container.querySelectorAll(".btn-remove-site").forEach((btn) => { container.querySelectorAll(".btn-remove-site").forEach((btn) => {
btn.addEventListener("click", async () => { btn.addEventListener("click", () => onRemove(btn.dataset.hostname));
const key = btn.dataset.key;
const host = btn.dataset.hostname;
for (const addr of Object.keys(state[key])) {
state[key][addr] = state[key][addr].filter((h) => h !== host);
if (state[key][addr].length === 0) {
delete state[key][addr];
}
}
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE" });
renderSiteList(containerId, state[key], key);
});
}); });
} }
// Drop a hostname from a remembered site list under every address.
function forgetHostname(siteMap, hostname) {
for (const addr of Object.keys(siteMap)) {
siteMap[addr] = siteMap[addr].filter((h) => h !== hostname);
if (siteMap[addr].length === 0) {
delete siteMap[addr];
}
}
}
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
// no longer allowed under any address, and the background ends its
// connections approved without "Remember" and tells its open tabs.
async function removeAllowedSite(hostname) {
forgetHostname(state.allowedSites, hostname);
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE", hostname });
await renderSiteLists();
}
// Removing a denied site only forgets the refusal; it connects nothing.
async function removeDeniedSite(hostname) {
forgetHostname(state.deniedSites, hostname);
await saveState();
await renderSiteLists();
}
function renderTrackedTokens() { function renderTrackedTokens() {
const container = $("settings-tracked-tokens"); const container = $("settings-tracked-tokens");
if (state.trackedTokens.length === 0) { if (state.trackedTokens.length === 0) {
@@ -202,13 +219,24 @@ function show() {
showView("settings"); showView("settings");
} }
function renderSiteLists() { async function renderSiteLists() {
renderSiteList( renderSiteList(
"settings-allowed-sites", "settings-allowed-sites",
state.allowedSites, Object.values(state.allowedSites).flat(),
"allowedSites", removeAllowedSite,
);
renderSiteList(
"settings-denied-sites",
Object.values(state.deniedSites).flat(),
removeDeniedSite,
);
// Sites allowed without "Remember" are held only by the background, in
// memory, so it is asked for them.
renderSiteList(
"settings-connected-sites",
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
removeAllowedSite,
); );
renderSiteList("settings-denied-sites", state.deniedSites, "deniedSites");
} }
function init(ctx) { function init(ctx) {
+9 -2
View File
@@ -153,8 +153,15 @@ function init(_ctx) {
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
} catch (e) { } catch (e) {
const detail = e.shortMessage || e.message || String(e); const detail = e.shortMessage || e.message || String(e);
log.errorf("Token lookup failed for", addr, detail); log.errorf("Adding token failed for", addr, detail);
showFlash(detail); // lookupTokenInfo() rejects a contract with a one-line message
// starting "Not a valid ERC-20 token". Any other error, such as a
// failed save, can be far longer, so it is only logged.
showFlash(
detail.startsWith("Not a valid ERC-20 token")
? detail
: "Could not add the token.",
);
infoEl.textContent = ""; infoEl.textContent = "";
infoEl.style.visibility = "hidden"; infoEl.style.visibility = "hidden";
} }
+175 -10
View File
@@ -2101,6 +2101,16 @@ describe("a site connection decided as the popup closes", () => {
}); });
}); });
// What a site is told when it asks which account it may use.
async function siteAccounts(bg, origin) {
const { sendResponse } = bg.send(
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
{ origin: origin || FRESH_ORIGIN },
);
await settle();
return sendResponse.mock.calls[0][0];
}
// A site connected without "Remember" is held only in the background's memory, // A site connected without "Remember" is held only in the background's memory,
// keyed to the address it was connected to. Removing that address, or the // keyed to the address it was connected to. Removing that address, or the
// wallet holding it, must end the connection as part of the removal itself. // wallet holding it, must end the connection as part of the removal itself.
@@ -2136,16 +2146,6 @@ describe("removing an address ends a site's connection to it", () => {
return bg; return bg;
} }
// What FRESH_ORIGIN is told when it asks which account it may use.
async function siteAccounts(bg) {
const { sendResponse } = bg.send(
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
{ origin: FRESH_ORIGIN },
);
await settle();
return sendResponse.mock.calls[0][0];
}
test("removing the connected address ends the connection", async () => { test("removing the connected address ends the connection", async () => {
const bg = await connectedBackground(); const bg = await connectedBackground();
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] }); expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
@@ -2192,3 +2192,168 @@ describe("removing an address ends a site's connection to it", () => {
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] }); expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
}); });
}); });
// Settings lists the sites allowed without "Remember", which only the
// background holds, and removing a site there, from either list, disconnects
// it. These drive the real Settings view against the real background and
// click the [x] the user clicks.
describe("removing a site in Settings disconnects it", () => {
// FRESH_ORIGIN on another port, so its hostname is FRESH_ORIGIN's.
const FRESH_OTHER_PORT = "https://fresh.example:8443";
// A site list's container. Its [x] buttons, data attributes and all, are
// read back out of the rows the view wrote into it, so clicking one runs
// the handler the view attached to it.
function fakeSiteList() {
const list = {
innerHTML: "",
buttons: [],
querySelectorAll() {
const tags = list.innerHTML.match(/<button[^>]*>/g) || [];
list.buttons = tags.map((tag) => ({
dataset: Object.fromEntries(
[...tag.matchAll(/data-(\w+)="([^"]*)"/g)].map(
(match) => [match[1], match[2]],
),
),
addEventListener(event, handler) {
this[event] = handler;
},
}));
return list.buttons;
},
};
return list;
}
// The hostnames a site list shows.
function listed(list) {
return [...list.innerHTML.matchAll(/data-hostname="([^"]*)"/g)].map(
(match) => match[1],
);
}
// A site connected the way the user does it, in the approval popup.
async function connect(bg, origin, remember) {
const pending = bg.requestSite(origin);
await settle();
bg.connectApproval(pending.id()).decide(true, remember);
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
}
// Settings, opened over the background's storage and wired to it the way
// the popup is: what Settings sends reaches the background from the
// extension's own page, and the answer comes back.
async function openSettings(bg) {
const lists = {};
const element = (id) => (lists[id] ||= fakeSiteList());
global.document = { getElementById: element };
global.chrome.runtime.sendMessage = (msg, callback) => {
const { sendResponse } = bg.send(msg, bg.fromPopup);
if (callback) callback(sendResponse.mock.calls[0]?.[0]);
};
await require("../src/shared/state").loadState();
await require("../src/popup/views/settings").renderSiteLists();
return {
allowed: element("settings-allowed-sites"),
connected: element("settings-connected-sites"),
// Click the [x] beside a site, and let what it sends run.
remove: async (list, hostname) => {
expect(listed(list)).toContain(hostname);
const button = list.buttons.find(
(b) => b.dataset.hostname === hostname,
);
await button.click();
await settle();
},
};
}
afterEach(() => {
delete global.document;
});
test("Settings lists a site connected without Remember", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
const settings = await openSettings(bg);
expect(listed(settings.connected)).toEqual(["fresh.example"]);
expect(listed(settings.allowed)).toEqual([HOSTNAME]);
});
test("removing a site connected without Remember disconnects it and tells its tabs", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
const sentToTabs = [];
global.chrome.tabs = {
query: (q, cb) =>
cb([
{ id: 1, url: FRESH_ORIGIN + "/app" },
{ id: 2, url: ORIGIN + "/app" },
]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push({ tabId, msg });
cb();
},
};
const settings = await openSettings(bg);
await settings.remove(settings.connected, "fresh.example");
expect(await siteAccounts(bg)).toEqual({ result: [] });
expect(sentToTabs).toEqual([
{
tabId: 1,
msg: {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
},
},
]);
expect(listed(settings.connected)).toEqual([]);
// The other site is untouched.
expect(await siteAccounts(bg, ORIGIN)).toEqual({
result: [signer.address],
});
});
// The same hostname can hold both kinds of connection: one origin allowed
// without Remember, then another, on a different port, allowed with it.
test("removing a remembered site also ends its connection made without Remember", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
await connect(bg, FRESH_OTHER_PORT, true);
const settings = await openSettings(bg);
await settings.remove(settings.allowed, "fresh.example");
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
result: [],
});
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
});
test("a page can neither remove a site nor list the connected ones", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
const page = { url: FRESH_ORIGIN + "/index.html" };
const remove = bg.send(
{ type: "AUTISTMASK_REMOVE_SITE", hostname: "fresh.example" },
page,
);
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);
expect(remove.sendResponse).toHaveBeenCalledWith({
error: "Unauthorized sender",
});
expect(list.sendResponse).toHaveBeenCalledWith({
error: "Unauthorized sender",
});
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
});
});
+7 -6
View File
@@ -99,12 +99,13 @@ describe("the flash line the message is shown in", () => {
// length, including one that wrapped to two lines and pushed the // length, including one that wrapped to two lines and pushed the
// settings view down 12px. // settings view down 12px.
// //
// The assertion that actually measures — empty line vs. the message, // The line cuts a message too long for it with an ellipsis (see
// real Chromium, documented 360x600 popup — is // showFlash() in src/popup/views/helpers.js). The assertions that
// "a rejected dust threshold shifts no layout (#233)" in // measure that, in a real browser at the documented 360x600 popup, are
// tests/e2e/run.js, run by make test-e2e. It is not in make check // "a rejected dust threshold shifts no layout (#233)" and "an over-long
// because REPO_POLICIES.md caps make test at 20 seconds and a browser // flash message keeps to one line (#252)" in tests/e2e/run.js, run by
// suite does not fit; run it before changing the wording. // make test-e2e. They are not in make check because REPO_POLICIES.md
// caps make test at 20 seconds and a browser suite does not fit.
test("reserves its height in the markup", () => { test("reserves its height in the markup", () => {
const flashLine = POPUP_HTML.match( const flashLine = POPUP_HTML.match(
/<div\s+id="flash-msg"\s+class="([^"]*)"/, /<div\s+id="flash-msg"\s+class="([^"]*)"/,
+40 -48
View File
@@ -1320,17 +1320,13 @@ async function waitForFilledFlashLine(page) {
} }
// README, No Layout Shift: the rejection message goes into #flash-msg, // README, No Layout Shift: the rejection message goes into #flash-msg,
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs. Reserving // whose min-h-[1.25rem] reserves exactly ONE line at text-xs, and which
// the space is not enough on its own — a message too long for one line // cuts a message too long for that line with an ellipsis rather than wrap
// wraps and pushes everything below it down anyway, which is what the // it. This shows the real message and measures that nothing moves; the
// first version of this change shipped: 75 characters, 32px, the settings // test after it does the same with a message several lines long. Both
// view and the threshold field 12px lower than with an empty line. // measure rather than inspect markup: the unit suite runs on the node
// // environment with no layout engine, where every height is zero (see the
// So this measures rather than inspects markup. It is the only assertion // note in tests/dustThreshold.test.js).
// 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) => { test("a rejected dust threshold shifts no layout (#233)", async (env) => {
const page = await openPopup(env.ctx, env.popupUrl); const page = await openPopup(env.ctx, env.popupUrl);
try { try {
@@ -1377,11 +1373,11 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
); );
assert( assert(
after.flashHeight === before.flashHeight, after.flashHeight === before.flashHeight,
"the message does not fit the reserved line: " + "the message does not keep to the reserved line: " +
before.flashHeight + before.flashHeight +
"px empty vs " + "px empty vs " +
after.flashHeight + after.flashHeight +
"px with the message. Shorten DUST_THRESHOLD_MESSAGE", "px with the message",
); );
assert( assert(
after.settingsTop === before.settingsTop, after.settingsTop === before.settingsTop,
@@ -1402,56 +1398,52 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
// ------------------------------------------------ the flash line (#252) // ------------------------------------------------ the flash line (#252)
// Every flash message must fit the one line #flash-msg reserves (see // #flash-msg never wraps: a message too long for its one line is cut with an
// showFlash() in src/popup/views/helpers.js). This drives the longest one // ellipsis (see showFlash() in src/popup/views/helpers.js). This puts a
// and measures the line's height with it. // message several lines long into it and measures that the line and the
// // screen below it stay where they were.
// The line is measured in the monospace font the popup declares. Firefox test("an over-long flash message keeps to one line (#252)", async (env) => {
// draws the popup in it; Chromium draws it in the system font instead
// (https://git.eeqj.de/sneak/AutistMask/issues/418), which is narrower, so a
// message that wraps in Firefox would still fit here and the test would pass.
test("the longest flash message fits on one line (#252)", async (env) => {
const page = await openPopup(env.ctx, env.popupUrl); const page = await openPopup(env.ctx, env.popupUrl);
try { try {
await page.setViewportSize(POPUP_VIEWPORT); await page.setViewportSize(POPUP_VIEWPORT);
await openSettings(page); await openSettings(page);
await page.click("#btn-settings-add-token");
await visible(page, "#view-settings-addtoken");
const font = await page.evaluate(() => {
const line = document.getElementById("flash-msg");
line.style.fontFamily = "var(--font-mono)";
return getComputedStyle(line).fontFamily;
});
assert(
font.includes("monospace"),
"the flash line is not in the monospace font: " + font,
);
const before = await page.evaluate(measureFlashLine); const before = await page.evaluate(measureFlashLine);
await page.fill("#settings-addtoken-address", "not an address"); const overflows = await page.evaluate(() => {
await page.click("#btn-settings-addtoken-manual"); const line = document.getElementById("flash-msg");
const after = await waitForFilledFlashLine(page); line.textContent =
"This message is far too long for one line. ".repeat(5);
return line.scrollWidth > line.clientWidth;
});
const after = await page.evaluate(measureFlashLine);
assert( assert(
after.flashHeight === before.flashHeight, after.flashHeight === before.flashHeight,
"the flash line is " + "the flash line is " +
before.flashHeight + before.flashHeight +
"px empty and " + "px before and " +
after.flashHeight + after.flashHeight +
"px with " + "px with an over-long message, so it wraps",
JSON.stringify(after.text) +
", so the message wraps",
); );
assert( assert(
after.text === "Enter a valid contract address starting with 0x.", after.settingsTop === before.settingsTop,
"the screen flashed " + "the settings view moved " +
JSON.stringify(after.text) + (after.settingsTop - before.settingsTop) +
", not the message this test measures", "px when the message appeared",
);
assert(
after.fieldTop === before.fieldTop,
"the dust threshold field moved " +
(after.fieldTop - before.fieldTop) +
"px when the message appeared",
);
// Checked last: a line that wraps does not run past its right edge,
// so this only shows the message really was cut once nothing moved.
assert(
overflows,
"the message fits on the line, so it proves nothing: " +
JSON.stringify(after.text),
); );
await page.click("#btn-settings-addtoken-back");
await visible(page, "#view-settings");
} finally { } finally {
await page.close(); await page.close();
} }
+130
View File
@@ -0,0 +1,130 @@
// The flash line (#252). #flash-msg reserves one line and cuts a message too
// long for it with an ellipsis; that is measured in a real browser by
// tests/e2e/run.js. Here: showFlash() keeps the whole message readable in the
// line's title, and the two add-token screens flash a fixed line, not the text
// of whatever error adding the token threw.
const ADDRESS = "0x1111111111111111111111111111111111111111";
let elements;
function fakeElement() {
return {
value: "",
textContent: "",
title: "",
style: {},
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
};
}
// Stands in for document.getElementById(): one fake element per id.
function element(id) {
return (elements[id] ||= fakeElement());
}
beforeEach(() => {
jest.resetModules();
elements = {};
globalThis.document = { getElementById: element };
// state.js reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
});
afterEach(() => {
jest.dontMock("../src/popup/views/helpers");
jest.dontMock("../src/shared/state");
jest.dontMock("../src/shared/balances");
jest.restoreAllMocks();
jest.useRealTimers();
delete globalThis.document;
delete globalThis.chrome;
});
test("showFlash() puts the whole message in the title, and clears both", () => {
jest.useFakeTimers();
const { showFlash } = require("../src/popup/views/helpers");
showFlash("Saved.");
expect(element("flash-msg").textContent).toBe("Saved.");
expect(element("flash-msg").title).toBe("Saved.");
jest.advanceTimersByTime(2000);
expect(element("flash-msg").textContent).toBe("");
expect(element("flash-msg").title).toBe("");
});
describe.each([
["addToken", "add-token-address", "btn-add-token-confirm"],
[
"settingsAddToken",
"settings-addtoken-address",
"btn-settings-addtoken-manual",
],
])("adding a token on %s", (view, field, button) => {
let flashes;
let errors;
// Clicks the screen's add button with lookupTokenInfo() and saveState()
// replaced by the given functions.
async function add(lookupTokenInfo, saveState) {
flashes = [];
errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest.spyOn(console, "log").mockImplementation(() => {});
jest.doMock("../src/shared/balances", () => ({ lookupTokenInfo }));
jest.doMock("../src/shared/state", () => ({
state: { trackedTokens: [] },
saveState,
}));
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s,
goBack: () => {},
}));
require("../src/popup/views/" + view).init({
doRefreshAndRender: () => {},
});
element(field).value = ADDRESS;
await element(button).listeners.click();
}
test("a failed save flashes a fixed line and logs the error", async () => {
const detail = "A sentence about the stored record. ".repeat(4);
await add(
async () => ({ symbol: "TKN", decimals: 18, name: "Token" }),
async () => {
throw new Error(detail);
},
);
expect(flashes).toEqual(["Could not add the token."]);
expect(errors).toHaveBeenCalledWith(
"[AutistMask]",
"Adding token failed for",
ADDRESS,
detail,
);
});
test("a contract that is not a token flashes the lookup message", async () => {
const detail = "Not a valid ERC-20 token (symbol() failed).";
await add(
async () => {
throw new Error(detail);
},
async () => {},
);
expect(flashes).toEqual([detail]);
});
});