Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aa4010f6c |
@@ -1437,7 +1437,9 @@ view would leave a wallet one click from deletion.
|
||||
- **Elements**:
|
||||
- "Transaction Broadcast" heading (no back button — tx is irreversible)
|
||||
- Amount + symbol
|
||||
- To: color dot + full address + etherscan link
|
||||
- To: color dot + full address + etherscan link; for a contract creation,
|
||||
which has no recipient, "This transaction creates a new contract. It has
|
||||
no recipient." instead
|
||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||
- Count-up timer: "Waiting for confirmation... Ns"
|
||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
||||
@@ -1466,7 +1468,8 @@ view would leave a wallet one click from deletion.
|
||||
- Decoded action well (shown when the transaction carried recognized
|
||||
calldata; the top-level Amount and To are hidden in that case)
|
||||
- Amount + symbol
|
||||
- To: color dot + full address + etherscan link
|
||||
- To: color dot + full address + etherscan link, or for a contract creation
|
||||
the same sentence as on WaitTx
|
||||
- Block number
|
||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||
- "Done" button
|
||||
@@ -1481,7 +1484,8 @@ view would leave a wallet one click from deletion.
|
||||
- **Elements**:
|
||||
- "Transaction Failed" heading
|
||||
- Amount + symbol
|
||||
- To: color dot + full address + etherscan link
|
||||
- To: color dot + full address + etherscan link, or for a contract creation
|
||||
the same sentence as on WaitTx
|
||||
- Error message (dashed border box)
|
||||
- Transaction hash section (hidden if broadcast failed before getting hash):
|
||||
full hash (tap to copy) + etherscan link
|
||||
@@ -1519,7 +1523,7 @@ view would leave a wallet one click from deletion.
|
||||
- From: blockie + color dot + full address (tap to copy) + etherscan link;
|
||||
ENS name if available
|
||||
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
|
||||
name if available
|
||||
name if available. For a contract creation, the same sentence as on WaitTx
|
||||
- Time: ISO datetime + relative age in parentheses
|
||||
- Block: block number (tap to copy) + etherscan block link
|
||||
- Amount: value + symbol (bold)
|
||||
@@ -1830,8 +1834,8 @@ view would leave a wallet one click from deletion.
|
||||
- Decoded action (if calldata is recognized): action name, token details,
|
||||
amounts, steps, deadline (see Transaction Decoding)
|
||||
- From: color dot + full address + etherscan link
|
||||
- Contract: color dot + full address + etherscan link (or "contract
|
||||
creation"), token symbol label if known
|
||||
- Contract: color dot + full address + etherscan link, token symbol label if
|
||||
known; for a contract creation, the same sentence as on WaitTx
|
||||
- Value: amount in ETH (4 decimal places, USD in parentheses)
|
||||
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
|
||||
in parentheses), with the gas limit and the fee per gas in gwei below it
|
||||
|
||||
@@ -45,17 +45,12 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 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 transaction with no `to` says "This transaction creates a new
|
||||
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: A method the wallet does not implement is refused with EIP-1193
|
||||
code `4200` ([#279](https://git.eeqj.de/sneak/AutistMask/issues/279)). The
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ============ -->
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const {
|
||||
$,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
escapeHtml,
|
||||
showView,
|
||||
showError,
|
||||
@@ -320,7 +321,7 @@ function showTxApproval(details) {
|
||||
toHtml += approvalAddressHtml(toAddr);
|
||||
$("approve-tx-to").innerHTML = toHtml;
|
||||
} else {
|
||||
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
|
||||
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
||||
}
|
||||
|
||||
const ethValueFormatted = formatTxValue(
|
||||
|
||||
@@ -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,6 +404,12 @@ function addressTitle(address, wallets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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.";
|
||||
|
||||
// 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.
|
||||
@@ -619,6 +621,7 @@ module.exports = {
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
formatAddressHtml,
|
||||
renderAddressHtml,
|
||||
copyableHtml,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
showFlash,
|
||||
flashCopyFeedback,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
addressDotHtml,
|
||||
escapeHtml,
|
||||
isoDate,
|
||||
@@ -94,13 +95,18 @@ function render() {
|
||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
||||
|
||||
const fromTitle = addressTitle(tx.from, state.wallets);
|
||||
const toTitle = addressTitle(tx.to, state.wallets);
|
||||
$("tx-detail-from").innerHTML = txAddressHtml(
|
||||
tx.from,
|
||||
tx.fromEns,
|
||||
fromTitle,
|
||||
);
|
||||
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
||||
// A contract creation has no recipient: transactions.js gives it `to: ""`.
|
||||
if (tx.to) {
|
||||
const toTitle = addressTitle(tx.to, state.wallets);
|
||||
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
||||
} else {
|
||||
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
||||
}
|
||||
|
||||
// Exact amount (full precision, copyable)
|
||||
const detailSym = displaySymbol(tx.symbol);
|
||||
|
||||
@@ -4,6 +4,7 @@ const {
|
||||
$,
|
||||
showView,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
escapeHtml,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
@@ -58,7 +59,10 @@ function endWait() {
|
||||
}
|
||||
}
|
||||
|
||||
// A contract creation reaches these screens with `to` as "" (approval.js
|
||||
// writes `to: toAddr || ""`).
|
||||
function toAddressHtml(address) {
|
||||
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
||||
const title = addressTitle(address, state.wallets);
|
||||
return renderAddressHtml(address, { title });
|
||||
}
|
||||
@@ -202,8 +206,9 @@ function restoreWait() {
|
||||
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
|
||||
// A string is the whole requirement: the empty string is what a
|
||||
// contract-deployment approval persists (approval.js writes `to: toAddr
|
||||
// || ""`), and both fields render harmlessly when empty, so refusing it
|
||||
// would abandon a wait the live path itself created.
|
||||
// || ""`), an empty `to` renders as a contract creation and an empty
|
||||
// amount renders harmlessly, so refusing it would abandon a wait the live
|
||||
// path itself created.
|
||||
if (typeof info.to !== "string") return false;
|
||||
if (typeof info.amount !== "string") return false;
|
||||
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
// The recipient line of a contract creation
|
||||
// (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, 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.
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
// The transaction detail view fetches on-chain details after drawing; an
|
||||
// answer that is not ok leaves the drawn lines as they are.
|
||||
debugFetch: async () => ({ ok: false }),
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
// The wait screen polls for a receipt; this one never arrives.
|
||||
jest.mock("../src/shared/balances", () => ({
|
||||
getProvider: () => ({ getTransactionReceipt: () => new Promise(() => {}) }),
|
||||
refreshBalances: () => {},
|
||||
}));
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
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 SENTENCE =
|
||||
"This transaction creates a new contract. It has no recipient.";
|
||||
|
||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const TX_HASH =
|
||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
||||
// Init code for a contract creation's data.
|
||||
const INIT_CODE = "0x600160005500";
|
||||
|
||||
function makeElement(id) {
|
||||
const classes = new Set();
|
||||
const el = {
|
||||
id,
|
||||
textContent: "",
|
||||
value: "",
|
||||
innerHTML: "",
|
||||
disabled: false,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: {
|
||||
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||
contains: (n) => classes.has(n),
|
||||
toggle: (n, force) => {
|
||||
const on = force === undefined ? !classes.has(n) : force;
|
||||
if (on) classes.add(n);
|
||||
else classes.delete(n);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
// Views reach for .parentElement to hide whole sections.
|
||||
Object.defineProperty(el, "parentElement", {
|
||||
get: () => node(id + "-parent"),
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeDocument() {
|
||||
const els = new Map();
|
||||
return {
|
||||
getElementById(id) {
|
||||
// The debug banner is created on demand by helpers.js; absent
|
||||
// is the state a non-debug, non-testnet popup is in.
|
||||
if (id === "debug-banner") return null;
|
||||
if (!els.has(id)) els.set(id, makeElement(id));
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => makeElement("created"),
|
||||
body: { prepend: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
function node(id) {
|
||||
return globalThis.document.getElementById(id);
|
||||
}
|
||||
|
||||
// The line a transaction with a real `to` shows: that address, and nothing
|
||||
// left over from an empty one.
|
||||
function expectAddressLine(html) {
|
||||
expect(html).toContain(RECIPIENT);
|
||||
expect(html).not.toContain(SENTENCE);
|
||||
expect(html).not.toContain("undefined");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.document = makeDocument();
|
||||
globalThis.window = { location: { search: "" } };
|
||||
state.wallets = [];
|
||||
state.trackedTokens = [];
|
||||
state.viewData = {};
|
||||
state.viewStack = [];
|
||||
state.currentView = null;
|
||||
txStatus.init({ doRefreshAndRender: () => {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
txStatus.endWait();
|
||||
});
|
||||
|
||||
// Open the transaction approval screen the way the popup does: the background
|
||||
// hands over the populated transaction and show() draws it.
|
||||
async function openTxApproval(to, data) {
|
||||
globalThis.chrome.runtime = {
|
||||
connect: () => ({ postMessage: () => {} }),
|
||||
sendMessage: (msg, reply) => {
|
||||
if (!reply) return;
|
||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
||||
reply({
|
||||
type: "tx",
|
||||
hostname: "dapp.example",
|
||||
isPhishingDomain: false,
|
||||
approvedFrom: FROM,
|
||||
approvedTx: {
|
||||
type: 2,
|
||||
from: FROM,
|
||||
chainId: "0x1",
|
||||
nonce: "0x7",
|
||||
gasLimit: "0x5208",
|
||||
maxPriorityFeePerGas: "0x3b9aca00",
|
||||
maxFeePerGas: "0x77359400",
|
||||
to,
|
||||
value: "0x0",
|
||||
data,
|
||||
accessList: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
approval.init({});
|
||||
await approval.show(1);
|
||||
}
|
||||
|
||||
describe("the transaction approval screen", () => {
|
||||
test("a contract creation says so instead of naming a contract", async () => {
|
||||
await openTxApproval(null, INIT_CODE);
|
||||
expect(node("approve-tx-to").innerHTML).toBe(SENTENCE);
|
||||
});
|
||||
|
||||
test("a transaction with a recipient shows its address", async () => {
|
||||
await openTxApproval(RECIPIENT, "0x");
|
||||
expectAddressLine(node("approve-tx-to").innerHTML);
|
||||
});
|
||||
});
|
||||
|
||||
// approval.js carries a contract creation to these screens with `to` as "".
|
||||
describe("the wait, success and error screens", () => {
|
||||
const creation = {
|
||||
to: "",
|
||||
amount: "0.0000",
|
||||
token: "ETH",
|
||||
tokenSymbol: null,
|
||||
};
|
||||
const transfer = { ...creation, to: RECIPIENT };
|
||||
|
||||
test("a contract creation says so on the wait screen", () => {
|
||||
txStatus.showWait(creation, TX_HASH);
|
||||
expect(node("wait-tx-to").innerHTML).toBe(SENTENCE);
|
||||
});
|
||||
|
||||
test("a transaction with a recipient shows its address on the wait screen", () => {
|
||||
txStatus.showWait(transfer, TX_HASH);
|
||||
expectAddressLine(node("wait-tx-to").innerHTML);
|
||||
});
|
||||
|
||||
test("a contract creation says so on the success and error screens", () => {
|
||||
state.viewData = {
|
||||
amount: "0.0000",
|
||||
symbol: "ETH",
|
||||
to: "",
|
||||
hash: TX_HASH,
|
||||
blockNumber: 1,
|
||||
};
|
||||
txStatus.renderSuccess();
|
||||
expect(node("success-tx-to").innerHTML).toBe(SENTENCE);
|
||||
|
||||
txStatus.showError(creation, TX_HASH, "The transaction failed.");
|
||||
expect(node("error-tx-to").innerHTML).toBe(SENTENCE);
|
||||
});
|
||||
|
||||
test("a transaction with a recipient shows its address on the success and error screens", () => {
|
||||
state.viewData = {
|
||||
amount: "0.0050",
|
||||
symbol: "ETH",
|
||||
to: RECIPIENT,
|
||||
hash: TX_HASH,
|
||||
blockNumber: 1,
|
||||
};
|
||||
txStatus.renderSuccess();
|
||||
expectAddressLine(node("success-tx-to").innerHTML);
|
||||
|
||||
txStatus.showError(transfer, TX_HASH, "The transaction failed.");
|
||||
expectAddressLine(node("error-tx-to").innerHTML);
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
expect(node("tx-detail-type").textContent).toBe("Contract Creation");
|
||||
});
|
||||
|
||||
test("a transaction with a recipient shows its address", () => {
|
||||
transactionDetail.show(historyTx(RECIPIENT));
|
||||
expectAddressLine(node("tx-detail-to").innerHTML);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user