1 Commits
Author SHA1 Message Date
sneak 5aa4010f6c fix: say a contract creation has no recipient instead of a blank line (closes #250)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 3s
A transaction with no `to` showed a blank recipient line on the wait,
success and error screens and the transaction detail view: an empty
address, with a colour dot whose colour was `undefined`. The approval
screen showed "(contract creation)".

All five now say "This transaction creates a new contract. It has no
recipient." A transaction with a real `to` is unchanged. The new test
drives each screen both ways.

Model: opus-5-5
2026-10-04 07:38:00 +00:00
18 changed files with 126 additions and 384 deletions
+1 -4
View File
@@ -1244,10 +1244,7 @@ view would leave a wallet one click from deletion.
of every wallet, deduplicated by hash and filtered. Each row is three
lines: age and direction, then the counterparty's colour dot (with our own
name for it, where it is one of our addresses) and the amount, then the
counterparty's full address on a row of its own. A contract creation has
no counterparty: its second line is the amount alone and its third line
says "This transaction creates a new contract. It has no recipient." The
transaction lists on AddressDetail and AddressToken draw the same rows
counterparty's full address on a row of its own
- "Add additional wallet..." link at bottom
- **Transitions**:
- Tap address row → sets the active address and broadcasts
+3 -16
View File
@@ -46,25 +46,12 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-04: A transaction with no `to` says "This transaction creates a new
contract. It has no recipient." on its recipient line and in its transaction
history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The
wait, success and error screens, the transaction detail view and the history
rows on Home, AddressDetail and AddressToken showed a blank address there,
contract. It has no recipient." on its recipient line
([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The wait, success
and error screens and the transaction detail view showed a blank line there,
with a colour dot whose colour was `undefined`; the approval screen showed
"(contract creation)". A transaction with a real `to` is unchanged.
- 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: A method the wallet does not implement is refused with EIP-1193
code `4200` ([#279](https://git.eeqj.de/sneak/AutistMask/issues/279)). The
background's `Unsupported method: <method>` error carried no code, so a site
+7 -3
View File
@@ -19,9 +19,13 @@
// that the user did not type — the same silent substitution the visible
// rejection message exists to end.
// Must render on ONE line of #flash-msg; see showFlash() in
// src/popup/views/helpers.js for how long that is.
const DUST_THRESHOLD_MESSAGE = "Enter a whole number of gwei, zero or greater.";
// Must render on ONE line of #flash-msg, whose reserved height
// (min-h-[1.25rem]) is exactly one line at text-xs. A string long enough to
// wrap to two lines pushes the settings view down, which the No Layout Shift
// policy forbids. Do not lengthen this without re-running the layout test in
// tests/e2e/run.js, which measures the flash line and goes red on a shift.
const DUST_THRESHOLD_MESSAGE =
"Please enter a whole number of gwei, zero or greater.";
// Returns the threshold in gwei, or null if the input is not one.
function parseDustThresholdGwei(raw) {
+1 -1
View File
@@ -33,7 +33,7 @@
<!-- ============ FLASH MESSAGE AREA ============ -->
<div
id="flash-msg"
class="text-xs text-muted min-h-[1.25rem] mb-1 truncate"
class="text-xs text-muted min-h-[1.25rem] mb-1"
></div>
<!-- ============ WELCOME / FIRST USE ============ -->
+5 -10
View File
@@ -28,7 +28,9 @@ function init(ctx) {
$("btn-add-token-confirm").addEventListener("click", async () => {
const contractAddr = $("add-token-address").value.trim();
if (!contractAddr || !contractAddr.startsWith("0x")) {
showFlash("Enter a valid contract address starting with 0x.");
showFlash(
"Please enter a valid contract address starting with 0x.",
);
return;
}
const already = state.trackedTokens.find(
@@ -69,15 +71,8 @@ function init(ctx) {
require("./addressDetail").show();
} catch (e) {
const detail = e.shortMessage || e.message || String(e);
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.",
);
log.errorf("Token lookup failed for", contractAddr, detail);
showFlash(detail);
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
}
+23 -10
View File
@@ -142,13 +142,15 @@ function validatePassword() {
async function importMnemonic(ctx) {
const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) {
showFlash("Enter a recovery phrase, or press the die.");
showFlash("Enter a recovery phrase or press the die to generate one.");
return;
}
const words = mnemonic.split(/\s+/);
if (words.length !== 12 && words.length !== 24) {
showFlash(
"Recovery phrase must be 12 or 24 words, not " + words.length + ".",
"Recovery phrase must be 12 or 24 words. You entered " +
words.length +
".",
);
return;
}
@@ -161,12 +163,14 @@ async function importMnemonic(ctx) {
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const xpubDup = findWalletByXpub(xpub);
if (xpubDup) {
showFlash("This recovery phrase is already added.");
showFlash(
"This recovery phrase is already added (" + xpubDup.name + ").",
);
return;
}
const addrDup = findWalletByAddress(firstAddress);
if (addrDup) {
showFlash("Address already exists in a wallet.");
showFlash("Address already exists in wallet (" + addrDup.name + ").");
return;
}
const encrypted = await encryptWithPassword(mnemonic, pw);
@@ -225,7 +229,9 @@ async function importPrivateKey(ctx) {
if (!pw) return;
const duplicate = findWalletByAddress(addr);
if (duplicate) {
showFlash("This address already exists in a wallet.");
showFlash(
"This address already exists in wallet (" + duplicate.name + ").",
);
return;
}
const encrypted = await encryptWithPassword(key, pw);
@@ -252,29 +258,36 @@ async function importXprvKey(ctx) {
return;
}
if (!isValidXprv(xprv)) {
showFlash("That extended private key is not valid.");
showFlash(
"That extended private key is not valid. Please check it and try again.",
);
return;
}
if (!isMasterExtendedKey(xprv)) {
showFlash("Please paste the master key, not a child key.");
showFlash(
"That is an account-level or child key, which cannot be imported. " +
"Please paste the master extended private key for the wallet.",
);
return;
}
let result;
try {
result = hdWalletFromXprv(xprv);
} catch {
showFlash("That extended private key is not valid.");
showFlash(
"That extended private key is not valid. Please check it and try again.",
);
return;
}
const { xpub, firstAddress } = result;
const xpubDup = findWalletByXpub(xpub);
if (xpubDup) {
showFlash("This key is already added.");
showFlash("This key is already added (" + xpubDup.name + ").");
return;
}
const addrDup = findWalletByAddress(firstAddress);
if (addrDup) {
showFlash("Address already exists in a wallet.");
showFlash("Address already exists in wallet (" + addrDup.name + ").");
return;
}
const pw = validatePassword();
+5 -2
View File
@@ -3,7 +3,7 @@ const {
showView,
showFlash,
balanceLinesForAddress,
txCounterpartyHtml,
addressDotHtml,
addressTitle,
escapeHtml,
displaySymbol,
@@ -233,13 +233,16 @@ function renderTransactions(txs) {
// is shown whole; the title or ENS name, where there is one, names
// it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`;
i++;
}
+5 -2
View File
@@ -6,7 +6,7 @@ const {
showView,
showFlash,
flashCopyFeedback,
txCounterpartyHtml,
addressDotHtml,
addressTitle,
escapeHtml,
displaySymbol,
@@ -309,13 +309,16 @@ function renderTransactions(txs) {
// is shown whole; the title or ENS name, where there is one, names
// it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`;
i++;
}
+7 -29
View File
@@ -223,19 +223,15 @@ function clearFlash() {
flashTimer = null;
}
$("flash-msg").textContent = "";
$("flash-msg").title = "";
}
// 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;
$("flash-msg").title = msg;
flashTimer = setTimeout(clearFlash, duration);
flashTimer = setTimeout(() => {
$("flash-msg").textContent = "";
flashTimer = null;
}, duration);
}
// A stored token balance as a number, or null when there is no number in it.
@@ -408,29 +404,12 @@ function addressTitle(address, wallets) {
return null;
}
// What every recipient line and history row says for a transaction with no
// `to`. Such a transaction creates a contract, so there is no address to show,
// and a blank line on these screens reads as a rendering fault.
// What every recipient line says for a transaction with no `to`. Such a
// transaction creates a contract, so there is no address to show, and a blank
// line on these screens reads as a rendering fault.
const CONTRACT_CREATION_TEXT =
"This transaction creates a new contract. It has no recipient.";
// The last two lines of a transaction history row: the counterparty's colour
// dot and name beside the amount, then its full address. A contract creation
// the user sent has no counterparty (its `to` is ""), so its row has the
// amount alone and the contract creation sentence in place of the address.
function txCounterpartyHtml(address, nameHtml, amountHtml) {
if (!address) {
return (
`<div class="flex justify-between"><span></span><span>${amountHtml}</span></div>` +
`<div>${escapeHtml(CONTRACT_CREATION_TEXT)}</div>`
);
}
return (
`<div class="flex justify-between"><span class="flex items-center">${addressDotHtml(address)}${nameHtml}</span><span>${amountHtml}</span></div>` +
`<div class="am-address">${escapeHtml(address)}</div>`
);
}
// Render an address with color dot, optional ENS name, optional title,
// and optional truncation. Title and ENS are shown as bold labels above
// the full address.
@@ -643,7 +622,6 @@ module.exports = {
displaySymbol,
addressTitle,
CONTRACT_CREATION_TEXT,
txCounterpartyHtml,
formatAddressHtml,
renderAddressHtml,
copyableHtml,
+4 -2
View File
@@ -6,7 +6,6 @@ const {
isoDate,
timeAgo,
addressDotHtml,
txCounterpartyHtml,
addressTitle,
escapeHtml,
displaySymbol,
@@ -123,13 +122,16 @@ function renderHomeTxList(ctx) {
// names it on the line above rather than replacing it.
const title = addressTitle(counterparty, state.wallets);
const titleStr = title ? escapeHtml(title) : "";
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${titleStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`;
i++;
}
+3 -3
View File
@@ -63,13 +63,13 @@ function validateToAddress(value) {
if (checksummed !== v) {
return {
valid: false,
error: "Address checksum is invalid. Check the address.",
error: "Address checksum is invalid. Please double-check the address.",
};
}
} catch {
return {
valid: false,
error: "Address checksum is invalid. Check the address.",
error: "Address checksum is invalid. Please double-check the address.",
};
}
}
@@ -211,7 +211,7 @@ function init(_ctx) {
const provider = getProvider(state.rpcUrl, state.networkId);
const resolved = await provider.resolveName(to);
if (!resolved) {
showFlash("That ENS name has no address.");
showFlash("Could not resolve " + to);
return;
}
resolvedTo = resolved;
+8 -2
View File
@@ -264,12 +264,18 @@ function init(ctx) {
const json = await resp.json();
if (json.error) {
log.errorf("RPC validation error:", json.error);
showFlash("Endpoint returned an error.");
showFlash("Endpoint returned error: " + json.error.message);
return;
}
const net = currentNetwork();
if (json.result !== net.chainId) {
showFlash("Wrong network: expected " + net.name + ".");
showFlash(
"Wrong network (expected " +
net.name +
", got chain " +
json.result +
").",
);
return;
}
} catch (e) {
+5 -10
View File
@@ -115,7 +115,9 @@ function init(_ctx) {
$("btn-settings-addtoken-manual").addEventListener("click", async () => {
const addr = $("settings-addtoken-address").value.trim();
if (!addr || !addr.startsWith("0x")) {
showFlash("Enter a valid contract address starting with 0x.");
showFlash(
"Please enter a valid contract address starting with 0x.",
);
return;
}
if (isTracked(addr)) {
@@ -153,15 +155,8 @@ function init(_ctx) {
ctx.doRefreshAndRender();
} catch (e) {
const detail = e.shortMessage || e.message || String(e);
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.",
);
log.errorf("Token lookup failed for", addr, detail);
showFlash(detail);
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
}
+6 -4
View File
@@ -41,10 +41,12 @@ const DEFECTS = {
"changed or removed, and this wallet stays until you delete " +
"it yourself.",
],
// One line, for the flash on a blocked Send and the inline error on
// the approval screens. It must fit on the flash line; see showFlash()
// in src/popup/views/helpers.js.
shortMessage: "This wallet cannot sign. See the wallet list.",
// One sentence for the places that have room for one: the flash on a
// blocked Send, the inline error on the approval screens.
shortMessage:
"This wallet cannot sign, because it was imported from an " +
"extended private key that is not a master key. The wallet list " +
"explains what happened.",
},
};
+24 -87
View File
@@ -2,11 +2,11 @@
// (https://git.eeqj.de/sneak/AutistMask/issues/250).
//
// A transaction with no `to` creates a contract. The approval screen, the
// wait, success and error screens, the transaction detail view and the
// transaction history rows each say so in a sentence, where they used to show
// a blank line (an empty address, with a colour dot whose colour was
// `undefined`) or, on the approval screen, "(contract creation)". A
// transaction with a real `to` still shows that address.
// wait, success and error screens, and the transaction detail view each say
// so in a sentence on the recipient line, where they used to show a blank
// line (an empty address, with a colour dot whose colour was `undefined`) or,
// on the approval screen, "(contract creation)". A transaction with a real
// `to` still shows that address.
//
// Driven against a minimal DOM stub in the shape
// tests/typedDataPermit.test.js uses.
@@ -31,19 +31,6 @@ jest.mock("../src/shared/balances", () => ({
refreshBalances: () => {},
}));
// The history lists ask the explorer for their transactions and resolve ENS
// names for them; here the explorer answers with mockHistory and no name
// resolves.
let mockHistory = [];
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: async () => mockHistory,
}));
jest.mock("../src/shared/ens", () => ({
...jest.requireActual("../src/shared/ens"),
resolveEnsNames: async () => new Map(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
@@ -52,9 +39,6 @@ const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const txStatus = require("../src/popup/views/txStatus");
const transactionDetail = require("../src/popup/views/transactionDetail");
const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const SENTENCE =
"This transaction creates a new contract. It has no recipient.";
@@ -89,7 +73,6 @@ function makeElement(id) {
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
@@ -236,29 +219,26 @@ describe("the wait, success and error screens", () => {
});
});
// A transaction FROM sent, as the history lists hold it. The explorer reports a
// contract creation with no `to`, which src/shared/transactions.js turns into
// `to: ""`.
function historyTx(to) {
return {
hash: TX_HASH,
from: FROM,
to,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
};
}
// The detail view is opened with the transaction a history row holds.
// The history list gives a contract creation `to: ""` (src/shared/
// transactions.js), and that is what the detail view is opened with.
describe("the transaction detail view", () => {
function historyTx(to) {
return {
hash: TX_HASH,
from: FROM,
to,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
};
}
test("a contract creation says so", () => {
transactionDetail.show(historyTx(""));
expect(node("tx-detail-to").innerHTML).toBe(SENTENCE);
@@ -270,46 +250,3 @@ describe("the transaction detail view", () => {
expectAddressLine(node("tx-detail-to").innerHTML);
});
});
// The same rows are drawn on Home, AddressDetail and AddressToken (for ETH).
describe.each([
["Home", "home-tx-list", () => home.render({})],
["AddressDetail", "tx-list", () => addressDetail.show()],
["AddressToken", "address-token-tx-list", () => addressToken.show()],
])("the transaction history on %s", (_name, listId, open) => {
async function rowsFor(tx) {
mockHistory = [tx];
open();
// The list is drawn once the history has been fetched.
await new Promise((resolve) => setTimeout(resolve, 0));
return node(listId).innerHTML;
}
beforeEach(() => {
state.wallets = [
{
name: "Main",
type: "key",
addresses: [{ address: FROM, balance: "0.0000" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
});
test("a contract creation's row says so, with no colour dot and no address line", async () => {
const html = await rowsFor(historyTx(""));
expect(html).toContain(SENTENCE);
expect(html).not.toContain("background:");
expect(html).not.toContain("am-address");
expect(html).not.toContain("undefined");
});
test("a transaction with a recipient shows its colour dot and address", async () => {
const html = await rowsFor(historyTx(RECIPIENT));
expectAddressLine(html);
expect(html).toContain("background:#");
expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`);
});
});
+6 -7
View File
@@ -99,13 +99,12 @@ 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 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.
// 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.
test("reserves its height in the markup", () => {
const flashLine = POPUP_HTML.match(
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
+13 -62
View File
@@ -1320,13 +1320,17 @@ 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, 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).
// 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.
test("a rejected dust threshold shifts no layout (#233)", async (env) => {
const page = await openPopup(env.ctx, env.popupUrl);
try {
@@ -1373,11 +1377,11 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
);
assert(
after.flashHeight === before.flashHeight,
"the message does not keep to the reserved line: " +
"the message does not fit the reserved line: " +
before.flashHeight +
"px empty vs " +
after.flashHeight +
"px with the message",
"px with the message. Shorten DUST_THRESHOLD_MESSAGE",
);
assert(
after.settingsTop === before.settingsTop,
@@ -1396,59 +1400,6 @@ test("a rejected dust threshold shifts no layout (#233)", async (env) => {
}
});
// ------------------------------------------------ the flash line (#252)
// #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);
const before = await page.evaluate(measureFlashLine);
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 before and " +
after.flashHeight +
"px with an over-long message, so it wraps",
);
assert(
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),
);
} finally {
await page.close();
}
});
// --------------------------------------------- confirmation screen (#238)
//
// The screen that decides what gets signed. The arithmetic underneath it
-130
View File
@@ -1,130 +0,0 @@
// 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]);
});
});