Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
effc452f3b | ||
|
|
49a7da87e8 |
@@ -1582,8 +1582,14 @@ view would leave a wallet one click from deletion.
|
||||
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
|
||||
threshold, so a number the user did not type is never stored.
|
||||
- Allowed Sites: list with remove buttons
|
||||
- Denied Sites: list with remove buttons
|
||||
- Allowed Sites: the hostnames remembered as allowed, under any address,
|
||||
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
|
||||
commit, which links to the commit in the repository
|
||||
- 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**
|
||||
- `[x]` on a wallet → **DeleteWallet**
|
||||
- Tap wallet name → inline rename field (no screen change)
|
||||
- `[x]` on a tracked token or a site → removes it in place (no screen
|
||||
change)
|
||||
- `[x]` on a tracked token → removes it in place (no screen 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)
|
||||
- "Back" (or Settings gear again) → previous screen (Home)
|
||||
|
||||
@@ -1790,7 +1803,8 @@ view would leave a wallet one click from deletion.
|
||||
- **Transitions**:
|
||||
- "Allow" / "Deny" → closes popup (returns result to background script; the
|
||||
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
|
||||
|
||||
#### TxApproval (`approve-tx`)
|
||||
|
||||
@@ -45,20 +45,30 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-04: Every flash message fits on the one line the flash line reserves,
|
||||
and a test measures it
|
||||
([#252](https://git.eeqj.de/sneak/AutistMask/issues/252)). A message that
|
||||
wrapped pushed the whole screen below it down. Rather than reserve a second
|
||||
line on every screen or let the flash cover the screen, every message is now
|
||||
at most 50 characters, one line of the popup's monospace font: the longer ones
|
||||
are reworded, and messages that carried a wallet name or text from a server no
|
||||
longer do, since neither has a length limit. The rule is written at
|
||||
`showFlash()` in `src/popup/views/helpers.js`. A new test in
|
||||
`tests/e2e/run.js` drives the longest message and fails if the line's rendered
|
||||
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
|
||||
- 2026-10-04: The flash line keeps to the one line it reserves at any message
|
||||
length ([#252](https://git.eeqj.de/sneak/AutistMask/issues/252)). A message
|
||||
that wrapped pushed the whole screen below it down. `#flash-msg` no longer
|
||||
wraps: text too long for the line is cut with an ellipsis, and `showFlash()`
|
||||
puts the whole message in the line's title. Every message is also reworded to
|
||||
at most 50 characters so none is cut; none carries a wallet name or text from
|
||||
a server, and the add-token screens flash a fixed line for any error other
|
||||
than a contract that is not a token. A new test in `tests/e2e/run.js` puts a
|
||||
message several lines long on the line and fails if the line or the screen
|
||||
below it moves. The two approval-screen error boxes are left to
|
||||
[#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
|
||||
connection approved without "Remember" for the addresses removed
|
||||
([#245](https://git.eeqj.de/sneak/AutistMask/issues/245)). Such a connection
|
||||
|
||||
+41
-1
@@ -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.
|
||||
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
|
||||
// fresh, so this naturally skips.
|
||||
@@ -1306,6 +1324,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
"AUTISTMASK_TX_RESPONSE",
|
||||
"AUTISTMASK_SIGN_RESPONSE",
|
||||
"AUTISTMASK_ADDRESSES_REMOVED",
|
||||
"AUTISTMASK_GET_CONNECTED_SITES",
|
||||
"AUTISTMASK_REMOVE_SITE",
|
||||
];
|
||||
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
||||
sendResponse({ error: "Unauthorized sender" });
|
||||
@@ -1662,8 +1682,28 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
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") {
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
+10
-1
@@ -33,7 +33,7 @@
|
||||
<!-- ============ FLASH MESSAGE AREA ============ -->
|
||||
<div
|
||||
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>
|
||||
|
||||
<!-- ============ WELCOME / FIRST USE ============ -->
|
||||
@@ -1064,6 +1064,15 @@
|
||||
<div id="settings-allowed-sites"></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">
|
||||
<h3 class="font-bold mb-1">Denied Sites</h3>
|
||||
<p class="text-xs text-muted mb-2">
|
||||
|
||||
@@ -69,8 +69,15 @@ function init(ctx) {
|
||||
require("./addressDetail").show();
|
||||
} catch (e) {
|
||||
const detail = e.shortMessage || e.message || String(e);
|
||||
log.errorf("Token lookup failed for", contractAddr, detail);
|
||||
showFlash(detail);
|
||||
log.errorf("Adding token failed for", contractAddr, 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.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
@@ -223,22 +223,19 @@ function clearFlash() {
|
||||
flashTimer = null;
|
||||
}
|
||||
$("flash-msg").textContent = "";
|
||||
$("flash-msg").title = "";
|
||||
}
|
||||
|
||||
// The flash line reserves the height of exactly one line, so a message that
|
||||
// wraps pushes the whole screen below it down, which README's No Layout Shift
|
||||
// rule forbids. Every message must fit on one line of the popup's monospace
|
||||
// font: 50 characters at most, and nothing of unbounded length, such as a
|
||||
// wallet name or text from a server, may be put into one. The longest message
|
||||
// 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.
|
||||
// The flash line reserves exactly one line, and a message that wrapped would
|
||||
// push the screen below it down (README, No Layout Shift). So #flash-msg never
|
||||
// wraps: text too long for the line is cut with an ellipsis, and the whole
|
||||
// message is also put in the line's title. Write messages to fit, at most 50
|
||||
// characters, so none is cut.
|
||||
function showFlash(msg, duration = 2000) {
|
||||
clearFlash();
|
||||
$("flash-msg").textContent = msg;
|
||||
flashTimer = setTimeout(() => {
|
||||
$("flash-msg").textContent = "";
|
||||
flashTimer = null;
|
||||
}, duration);
|
||||
$("flash-msg").title = msg;
|
||||
flashTimer = setTimeout(clearFlash, duration);
|
||||
}
|
||||
|
||||
// A stored token balance as a number, or null when there is no number in it.
|
||||
|
||||
+49
-21
@@ -29,44 +29,61 @@ const {
|
||||
GITEA_COMMIT_URL,
|
||||
} = require("../../shared/buildInfo");
|
||||
|
||||
const { notify } = require("../../shared/browserApi");
|
||||
const { notify, sendMessage } = require("../../shared/browserApi");
|
||||
|
||||
let versionClickCount = 0;
|
||||
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 hostnames = [...new Set(Object.values(siteMap).flat())];
|
||||
if (hostnames.length === 0) {
|
||||
const unique = [...new Set(hostnames)];
|
||||
if (unique.length === 0) {
|
||||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||||
return;
|
||||
}
|
||||
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">`;
|
||||
// A hostname the URL parser produced cannot carry a delimiter, so
|
||||
// this is escaped for the rule rather than for a known hole — the
|
||||
// rule being that nothing reaches innerHTML unescaped.
|
||||
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>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
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];
|
||||
btn.addEventListener("click", () => onRemove(btn.dataset.hostname));
|
||||
});
|
||||
}
|
||||
|
||||
// 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" });
|
||||
renderSiteList(containerId, state[key], key);
|
||||
});
|
||||
});
|
||||
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() {
|
||||
@@ -202,13 +219,24 @@ function show() {
|
||||
showView("settings");
|
||||
}
|
||||
|
||||
function renderSiteLists() {
|
||||
async function renderSiteLists() {
|
||||
renderSiteList(
|
||||
"settings-allowed-sites",
|
||||
state.allowedSites,
|
||||
"allowedSites",
|
||||
Object.values(state.allowedSites).flat(),
|
||||
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) {
|
||||
|
||||
@@ -153,8 +153,15 @@ function init(_ctx) {
|
||||
ctx.doRefreshAndRender();
|
||||
} catch (e) {
|
||||
const detail = e.shortMessage || e.message || String(e);
|
||||
log.errorf("Token lookup failed for", addr, detail);
|
||||
showFlash(detail);
|
||||
log.errorf("Adding token failed for", addr, 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.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
// 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.
|
||||
@@ -2136,16 +2146,6 @@ describe("removing an address ends a site's connection to it", () => {
|
||||
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 () => {
|
||||
const bg = await connectedBackground();
|
||||
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] });
|
||||
});
|
||||
});
|
||||
|
||||
// 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] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,12 +99,13 @@ describe("the flash line the message is shown in", () => {
|
||||
// length, including one that wrapped to two lines and pushed the
|
||||
// settings view down 12px.
|
||||
//
|
||||
// The assertion that actually measures — empty line vs. the message,
|
||||
// real Chromium, documented 360x600 popup — is
|
||||
// "a rejected dust threshold shifts no layout (#233)" in
|
||||
// tests/e2e/run.js, run by make test-e2e. It is not in make check
|
||||
// because REPO_POLICIES.md caps make test at 20 seconds and a browser
|
||||
// suite does not fit; run it before changing the wording.
|
||||
// The line cuts a message too long for it with an ellipsis (see
|
||||
// showFlash() in src/popup/views/helpers.js). The assertions that
|
||||
// measure that, in a real browser at the documented 360x600 popup, are
|
||||
// "a rejected dust threshold shifts no layout (#233)" and "an over-long
|
||||
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by
|
||||
// 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", () => {
|
||||
const flashLine = POPUP_HTML.match(
|
||||
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
|
||||
|
||||
+40
-48
@@ -1320,17 +1320,13 @@ async function waitForFilledFlashLine(page) {
|
||||
}
|
||||
|
||||
// README, No Layout Shift: the rejection message goes into #flash-msg,
|
||||
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs. Reserving
|
||||
// the space is not enough on its own — a message too long for one line
|
||||
// wraps and pushes everything below it down anyway, which is what the
|
||||
// first version of this change shipped: 75 characters, 32px, the settings
|
||||
// view and the threshold field 12px lower than with an empty line.
|
||||
//
|
||||
// So this measures rather than inspects markup. It is the only assertion
|
||||
// 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.
|
||||
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs, and which
|
||||
// cuts a message too long for that line with an ellipsis rather than wrap
|
||||
// it. This shows the real message and measures that nothing moves; the
|
||||
// test after it does the same with a message several lines long. Both
|
||||
// measure rather than inspect markup: 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).
|
||||
test("a rejected dust threshold shifts no layout (#233)", async (env) => {
|
||||
const page = await openPopup(env.ctx, env.popupUrl);
|
||||
try {
|
||||
@@ -1377,11 +1373,11 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
|
||||
);
|
||||
assert(
|
||||
after.flashHeight === before.flashHeight,
|
||||
"the message does not fit the reserved line: " +
|
||||
"the message does not keep to the reserved line: " +
|
||||
before.flashHeight +
|
||||
"px empty vs " +
|
||||
after.flashHeight +
|
||||
"px with the message. Shorten DUST_THRESHOLD_MESSAGE",
|
||||
"px with the message",
|
||||
);
|
||||
assert(
|
||||
after.settingsTop === before.settingsTop,
|
||||
@@ -1402,56 +1398,52 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
|
||||
|
||||
// ------------------------------------------------ the flash line (#252)
|
||||
|
||||
// Every flash message must fit the one line #flash-msg reserves (see
|
||||
// showFlash() in src/popup/views/helpers.js). This drives the longest one
|
||||
// and measures the line's height with it.
|
||||
//
|
||||
// The line is measured in the monospace font the popup declares. Firefox
|
||||
// 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) => {
|
||||
// #flash-msg never wraps: a message too long for its one line is cut with an
|
||||
// ellipsis (see showFlash() in src/popup/views/helpers.js). This puts a
|
||||
// message several lines long into it and measures that the line and the
|
||||
// screen below it stay where they were.
|
||||
test("an over-long flash message keeps to one line (#252)", async (env) => {
|
||||
const page = await openPopup(env.ctx, env.popupUrl);
|
||||
try {
|
||||
await page.setViewportSize(POPUP_VIEWPORT);
|
||||
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);
|
||||
await page.fill("#settings-addtoken-address", "not an address");
|
||||
await page.click("#btn-settings-addtoken-manual");
|
||||
const after = await waitForFilledFlashLine(page);
|
||||
const overflows = await page.evaluate(() => {
|
||||
const line = document.getElementById("flash-msg");
|
||||
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(
|
||||
after.flashHeight === before.flashHeight,
|
||||
"the flash line is " +
|
||||
before.flashHeight +
|
||||
"px empty and " +
|
||||
"px before and " +
|
||||
after.flashHeight +
|
||||
"px with " +
|
||||
JSON.stringify(after.text) +
|
||||
", so the message wraps",
|
||||
"px with an over-long message, so it wraps",
|
||||
);
|
||||
assert(
|
||||
after.text === "Enter a valid contract address starting with 0x.",
|
||||
"the screen flashed " +
|
||||
JSON.stringify(after.text) +
|
||||
", not the message this test measures",
|
||||
after.settingsTop === before.settingsTop,
|
||||
"the settings view moved " +
|
||||
(after.settingsTop - before.settingsTop) +
|
||||
"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 {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user