Compare commits
2
Commits
4f450fee55
...
c5cd3fe330
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5cd3fe330 | ||
|
|
eec3e23099 |
@@ -45,6 +45,28 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-05: The Chrome end-to-end suite drives the private key export screen
|
||||
as it drives the recovery phrase screen
|
||||
([#253](https://git.eeqj.de/sneak/AutistMask/issues/253)): the correct
|
||||
password shows the key, leaving by the settings gear empties the screen, and
|
||||
leaving while the password is still being checked never puts the key on it.
|
||||
The cases use the imported key wallet rather than the HD one. Leaving drops
|
||||
the address the screen was showing, and an HD wallet's key cannot be derived
|
||||
without it, so on an HD wallet a late decrypt fails by itself and would never
|
||||
exercise the check that discards it. The screen cannot yet be opened twice in
|
||||
one popup session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)),
|
||||
so the cases reopen the popup before the second open.
|
||||
|
||||
- 2026-10-05: Escaping in the popup's views follows its own rule with no
|
||||
exceptions ([#329](https://git.eeqj.de/sneak/AutistMask/issues/329)). The
|
||||
decimals and holder count on a token's screen, and every USD figure (the ETH
|
||||
price, each total and each balance row's value), went into `innerHTML`
|
||||
unescaped; they are escaped now. None could carry markup, but `formatUsd()`
|
||||
writes a value under a cent as `< $0.01`. `displaySymbol()` counts a symbol in
|
||||
code points rather than UTF-16 units, so a cut never splits an emoji into a
|
||||
half that renders as U+FFFD. `explorerLink()`, also named in the issue, was
|
||||
already removed by [#168](https://git.eeqj.de/sneak/AutistMask/issues/168).
|
||||
|
||||
- 2026-10-05: A Chrome end-to-end test that fails no longer takes later tests
|
||||
down with it ([#318](https://git.eeqj.de/sneak/AutistMask/issues/318)). Each
|
||||
test that turns a fixture switch on for itself alone (a held or failing gas
|
||||
|
||||
@@ -66,7 +66,7 @@ function show() {
|
||||
$("address-line").dataset.full = addr.address;
|
||||
attachCopyHandlers($("address-line"));
|
||||
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
||||
$("address-usd-total").innerHTML = usdTotal || " ";
|
||||
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
|
||||
const ensEl = $("address-ens");
|
||||
// ENS is now shown inside renderAddressHtml, hide the separate element
|
||||
ensEl.classList.add("hidden");
|
||||
|
||||
@@ -103,7 +103,7 @@ function show() {
|
||||
// USD total for this token only
|
||||
const usdVal = price && amount !== null ? amount * price : null;
|
||||
const usdStr = formatUsd(usdVal);
|
||||
$("address-token-usd-total").innerHTML = usdStr || " ";
|
||||
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " ";
|
||||
|
||||
// Single token balance line (no tokenId — not clickable here)
|
||||
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
|
||||
@@ -148,9 +148,9 @@ function show() {
|
||||
if (tokenSymbol)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
|
||||
if (tokenDecimals != null)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${escapeHtml(tokenDecimals)}</div>`;
|
||||
if (tokenHolders != null)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${escapeHtml(Number(tokenHolders).toLocaleString())}</div>`;
|
||||
if (projectUrl)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
|
||||
contractInfo.innerHTML = infoHtml;
|
||||
|
||||
@@ -325,7 +325,7 @@ function balanceLine(symbol, amount, price, tokenId) {
|
||||
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||
const usd =
|
||||
price && amount !== null
|
||||
? formatUsd(amount * price) || " "
|
||||
? escapeHtml(formatUsd(amount * price)) || " "
|
||||
: " ";
|
||||
// tokenId is a contract address out of the same explorer JSON, and it
|
||||
// lands inside a quoted attribute.
|
||||
|
||||
@@ -63,7 +63,7 @@ function renderTotalValue() {
|
||||
const ethPrice = getPrice("ETH");
|
||||
if (priceEl) {
|
||||
priceEl.innerHTML = ethPrice
|
||||
? formatUsd(ethPrice) + " USD/ETH"
|
||||
? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
|
||||
: " ";
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ function renderTotalValue() {
|
||||
el.textContent = ethStr + ethUsd;
|
||||
|
||||
if (subEl) {
|
||||
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || " ";
|
||||
subEl.innerHTML =
|
||||
escapeHtml(formatAddressTotal(getAddressValue(addr))) || " ";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +281,7 @@ function walletListHtml() {
|
||||
}
|
||||
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
||||
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || " "}</div>`;
|
||||
html += balanceLinesForAddress(
|
||||
addr,
|
||||
state.trackedTokens,
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
|
||||
// truncated. The ellipsis is what tells the user the name they are looking
|
||||
// at is not the whole name — worth knowing before they send to it.
|
||||
//
|
||||
// Characters are counted as code points, not UTF-16 units, so an emoji is
|
||||
// one character and the cut never falls between the two halves of one: a
|
||||
// half on its own renders as U+FFFD.
|
||||
|
||||
const MAX_SYMBOL_LENGTH = 12;
|
||||
|
||||
@@ -32,8 +36,9 @@ const UNKNOWN_SYMBOL = "???";
|
||||
function displaySymbol(symbol) {
|
||||
const s = symbol === null || symbol === undefined ? "" : String(symbol);
|
||||
if (s.length === 0) return UNKNOWN_SYMBOL;
|
||||
if (s.length <= MAX_SYMBOL_LENGTH) return s;
|
||||
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
|
||||
const chars = Array.from(s);
|
||||
if (chars.length <= MAX_SYMBOL_LENGTH) return s;
|
||||
return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -194,6 +194,14 @@ describe("the wallet list on Home", () => {
|
||||
clearPrices();
|
||||
expect(walletListTotal(FULLY_PRICED)).toBe(" ");
|
||||
});
|
||||
|
||||
// A total under a cent is written "< $0.01", and the "<" is escaped
|
||||
// here as the removal warning escapes it.
|
||||
test("a total under a cent is escaped, as on the removal warning", () => {
|
||||
const tiny = { ...EMPTY, balance: "0.000001" };
|
||||
expect(walletListTotal(tiny)).toBe("Total: < $0.01");
|
||||
expect(removalWarningTotal(tiny)).toBe("Total: < $0.01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the balance warning on the address-removal confirmation", () => {
|
||||
|
||||
@@ -66,4 +66,11 @@ describe("balanceLine", () => {
|
||||
expect(html).toContain("<span>1.5000</span>");
|
||||
expect(html).toContain('data-token="0xabc"');
|
||||
});
|
||||
|
||||
// formatUsd() writes a value under a cent as "< $0.01".
|
||||
test("escapes the USD value along with the symbol", () => {
|
||||
const html = balanceLine("USDC", 0.001, 1, null);
|
||||
expect(html).toContain("< $0.01");
|
||||
expect(html).not.toContain("< $0.01");
|
||||
});
|
||||
});
|
||||
|
||||
+166
-30
@@ -485,22 +485,26 @@ async function openSettings(page) {
|
||||
await visible(page, "#view-settings");
|
||||
}
|
||||
|
||||
// Everything the recovery phrase screen is holding, read straight out of
|
||||
// Everything a screen that shows a secret is holding, read straight out of
|
||||
// the DOM whether or not that screen is the one on top. Reading it while it
|
||||
// is hidden is the point: "cleared on leave" means the node is empty, not
|
||||
// merely off-screen.
|
||||
async function phraseScreenState(page) {
|
||||
return page.evaluate(() => ({
|
||||
value: document.getElementById("show-phrase-value").textContent,
|
||||
error: document.getElementById("show-phrase-flash").textContent,
|
||||
html: document.getElementById("view-show-phrase").innerHTML,
|
||||
resultHidden: document
|
||||
.getElementById("show-phrase-result")
|
||||
.classList.contains("hidden"),
|
||||
viewHidden: document
|
||||
.getElementById("view-show-phrase")
|
||||
.classList.contains("hidden"),
|
||||
}));
|
||||
// merely off-screen. `view` is "show-phrase" or "export-privkey"; the two
|
||||
// screens name their elements the same way.
|
||||
async function secretScreenState(page, view) {
|
||||
return page.evaluate(
|
||||
(v) => ({
|
||||
value: document.getElementById(v + "-value").textContent,
|
||||
error: document.getElementById(v + "-flash").textContent,
|
||||
html: document.getElementById("view-" + v).innerHTML,
|
||||
resultHidden: document
|
||||
.getElementById(v + "-result")
|
||||
.classList.contains("hidden"),
|
||||
viewHidden: document
|
||||
.getElementById("view-" + v)
|
||||
.classList.contains("hidden"),
|
||||
}),
|
||||
view,
|
||||
);
|
||||
}
|
||||
|
||||
async function openPhraseScreen(page) {
|
||||
@@ -515,12 +519,12 @@ async function revealPhrase(page) {
|
||||
await visible(page, "#show-phrase-result", 60000);
|
||||
}
|
||||
|
||||
function assertWiped(st, phrase, where) {
|
||||
assert(st.value === "", "phrase still in the DOM " + where);
|
||||
function assertWiped(st, secret, where) {
|
||||
assert(st.value === "", "the secret is still in the DOM " + where);
|
||||
assert(st.resultHidden, "result section still shown " + where);
|
||||
assert(
|
||||
!st.html.includes(phrase),
|
||||
"the recovery phrase is still somewhere in the screen markup " + where,
|
||||
!st.html.includes(secret),
|
||||
"the secret is still somewhere in the screen markup " + where,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,7 +546,8 @@ test("only an HD wallet is offered the recovery phrase action (#161)", async (en
|
||||
// The other half of the gate, against the real UI: a wallet holding a bare
|
||||
// private key has no phrase to show, so no row of it may offer the action.
|
||||
// The key is generated here rather than committed — the repo holds no
|
||||
// private keys, test ones included.
|
||||
// private keys, test ones included. It is kept on env for the private key
|
||||
// export tests (#253).
|
||||
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
|
||||
const { Wallet } = require("ethers");
|
||||
|
||||
@@ -550,10 +555,8 @@ test("a key wallet is not offered the recovery phrase action (#161)", async (env
|
||||
await env.page.click("#btn-main-add-wallet");
|
||||
await visible(env.page, "#view-add-wallet");
|
||||
await env.page.click("#tab-privkey");
|
||||
await env.page.fill(
|
||||
"#import-private-key",
|
||||
Wallet.createRandom().privateKey,
|
||||
);
|
||||
env.privateKey = Wallet.createRandom().privateKey;
|
||||
await env.page.fill("#import-private-key", env.privateKey);
|
||||
await env.page.fill("#add-wallet-password", PASSWORD);
|
||||
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
|
||||
await env.page.click("#btn-add-wallet-confirm");
|
||||
@@ -575,7 +578,7 @@ test("a key wallet is not offered the recovery phrase action (#161)", async (env
|
||||
|
||||
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
|
||||
await openPhraseScreen(env.page);
|
||||
const st = await phraseScreenState(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
assertWiped(st, env.phrase, "before any password was entered");
|
||||
const passwordShown = await env.page.isVisible(
|
||||
"#show-phrase-password-section",
|
||||
@@ -593,7 +596,7 @@ test("a wrong password reveals nothing (#161)", async (env) => {
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
|
||||
const st = await phraseScreenState(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
assertWiped(st, env.phrase, "after a wrong password");
|
||||
assert(
|
||||
/^[A-Z].*\.$/.test(st.error.trim()),
|
||||
@@ -609,7 +612,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
|
||||
try {
|
||||
await revealPhrase(env.page);
|
||||
|
||||
const st = await phraseScreenState(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
assert(
|
||||
st.value === env.phrase,
|
||||
"the displayed phrase is not the wallet's phrase, verbatim",
|
||||
@@ -640,7 +643,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
|
||||
test('"Back" wipes the revealed phrase (#161)', async (env) => {
|
||||
await env.page.click("#btn-show-phrase-back");
|
||||
await visible(env.page, "#view-settings");
|
||||
const st = await phraseScreenState(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
||||
assertWiped(st, env.phrase, "after Back");
|
||||
});
|
||||
@@ -652,7 +655,7 @@ test("leaving by the settings gear wipes it too (#161)", async (env) => {
|
||||
await revealPhrase(env.page);
|
||||
await env.page.click("#btn-settings");
|
||||
await visible(env.page, "#view-settings");
|
||||
const st = await phraseScreenState(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
assertWiped(st, env.phrase, "after leaving via the settings gear");
|
||||
});
|
||||
|
||||
@@ -689,7 +692,7 @@ test("leaving while the decrypt is in flight reveals nothing (#161)", async (env
|
||||
);
|
||||
await sleep(2000);
|
||||
|
||||
const st = await phraseScreenState(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
// Printed on every run, pass or fail: "the phrase is not there" is
|
||||
// worth more as a measurement than as a silent assertion, and the
|
||||
// same line read from a build without the guard is what this test
|
||||
@@ -726,11 +729,141 @@ test("reopening the popup never lands on the phrase screen (#161)", async (env)
|
||||
env.page = await openPopup(env.ctx, env.popupUrl);
|
||||
await visible(env.page, "#view-main");
|
||||
|
||||
const st = await phraseScreenState(env.page);
|
||||
const st = await secretScreenState(env.page, "show-phrase");
|
||||
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
|
||||
assertWiped(st, env.phrase, "after reopening the popup");
|
||||
});
|
||||
|
||||
// ------------------------------------------ private key export (#253)
|
||||
|
||||
// The recovery phrase cases above, on the private key export screen. They run
|
||||
// against the key wallet imported above, not the HD wallet: leaving the screen
|
||||
// drops the address it was showing, and without one an HD wallet's key cannot
|
||||
// be derived, so there a decrypt that finished late would fail on its own and
|
||||
// the liveness check would go untested.
|
||||
|
||||
// From Home to the export screen of the key wallet's one address. The key
|
||||
// wallet is the second wallet in the list.
|
||||
async function openPrivkeyScreen(page) {
|
||||
await visible(page, "#view-main");
|
||||
await page.click('#wallet-list .btn-addr-info[data-wallet="1"]');
|
||||
await visible(page, "#view-address");
|
||||
await page.click("#btn-more-menu");
|
||||
await page.click("#btn-export-privkey");
|
||||
await visible(page, "#view-export-privkey");
|
||||
}
|
||||
|
||||
async function revealPrivkey(page) {
|
||||
await page.fill("#export-privkey-password", PASSWORD);
|
||||
await page.click("#btn-export-privkey-confirm");
|
||||
await visible(page, "#export-privkey-result", 60000);
|
||||
}
|
||||
|
||||
// Leave the export screen, or the Settings screen the gear left it for, for
|
||||
// Home. The gear put the export screen on the Back stack, so from Settings the
|
||||
// way home passes through it, already emptied
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
|
||||
async function leavePrivkeyScreen(page) {
|
||||
if (await page.isVisible("#view-settings")) {
|
||||
await page.click("#btn-settings-back");
|
||||
await visible(page, "#view-export-privkey");
|
||||
}
|
||||
if (await page.isVisible("#view-export-privkey")) {
|
||||
await page.click("#btn-export-privkey-back");
|
||||
await visible(page, "#view-address");
|
||||
}
|
||||
if (await page.isVisible("#view-address")) {
|
||||
await page.click("#btn-address-back");
|
||||
}
|
||||
await visible(page, "#view-main");
|
||||
}
|
||||
|
||||
test("the correct password reveals the private key, and nothing logs it (#253)", async (env) => {
|
||||
const console_ = [];
|
||||
const listener = (msg) => console_.push(msg.text());
|
||||
env.page.on("console", listener);
|
||||
try {
|
||||
await openPrivkeyScreen(env.page);
|
||||
await revealPrivkey(env.page);
|
||||
|
||||
const st = await secretScreenState(env.page, "export-privkey");
|
||||
assert(
|
||||
st.value === env.privateKey,
|
||||
"the displayed key is not the wallet's private key, verbatim",
|
||||
);
|
||||
const promptShown = await env.page.isVisible(
|
||||
"#export-privkey-password-section",
|
||||
);
|
||||
assert(!promptShown, "the password prompt is still shown after unlock");
|
||||
|
||||
const title = await env.page.getAttribute(
|
||||
"#export-privkey-value",
|
||||
"title",
|
||||
);
|
||||
assert(title === "Click to copy", "the key is not click-to-copy");
|
||||
|
||||
const leaked = console_.filter((line) => line.includes(env.privateKey));
|
||||
assert(
|
||||
leaked.length === 0,
|
||||
"the private key reached the console: " + JSON.stringify(leaked),
|
||||
);
|
||||
} finally {
|
||||
env.page.off("console", listener);
|
||||
}
|
||||
});
|
||||
|
||||
test("leaving by the settings gear wipes the private key (#253)", async (env) => {
|
||||
await visible(env.page, "#export-privkey-result");
|
||||
await env.page.click("#btn-settings");
|
||||
await visible(env.page, "#view-settings");
|
||||
const st = await secretScreenState(env.page, "export-privkey");
|
||||
assertWiped(st, env.privateKey, "after leaving via the settings gear");
|
||||
});
|
||||
|
||||
// The same interleaving as the recovery phrase case above, and for the same
|
||||
// reason: both clicks in one page task, so the leave and its wipe run while
|
||||
// the decrypt is still awaited. Reveal stays disabled while the decrypt runs,
|
||||
// so reading it after the gear click shows the leave really came mid-decrypt.
|
||||
test("leaving while the decrypt is in flight reveals no private key (#253)", async (env) => {
|
||||
try {
|
||||
await leavePrivkeyScreen(env.page);
|
||||
// The export screen cannot yet be opened twice in one popup session
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/460), so this second
|
||||
// open gets a fresh one.
|
||||
await reopenPopup(env, "main");
|
||||
await openPrivkeyScreen(env.page);
|
||||
await env.page.fill("#export-privkey-password", PASSWORD);
|
||||
const inFlight = await env.page.evaluate(() => {
|
||||
const reveal = document.getElementById(
|
||||
"btn-export-privkey-confirm",
|
||||
);
|
||||
reveal.click();
|
||||
document.getElementById("btn-settings").click();
|
||||
return reveal.disabled;
|
||||
});
|
||||
assert(
|
||||
inFlight,
|
||||
"the decrypt was not running when the screen was left",
|
||||
);
|
||||
await visible(env.page, "#view-settings");
|
||||
|
||||
// Reveal is re-enabled in the same continuation that would have
|
||||
// written the key, so once it is back the decrypt has finished.
|
||||
await env.page.waitForFunction(
|
||||
() =>
|
||||
!document.getElementById("btn-export-privkey-confirm").disabled,
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
|
||||
const st = await secretScreenState(env.page, "export-privkey");
|
||||
assert(st.viewHidden, "the private key screen is still on top");
|
||||
assertWiped(st, env.privateKey, "after leaving mid-decrypt");
|
||||
} finally {
|
||||
await leavePrivkeyScreen(env.page);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------- Back after reopening the popup (#268)
|
||||
|
||||
// A reopened popup renders the wallet list and the view it restores onto,
|
||||
@@ -4080,6 +4213,9 @@ async function main() {
|
||||
// The recovery phrase of the wallet created in test 2, so later
|
||||
// tests can assert on the real secret rather than its shape.
|
||||
phrase: null,
|
||||
// The private key of the key wallet imported by the recovery phrase
|
||||
// tests (#161), asserted on by the private key export tests (#253).
|
||||
privateKey: null,
|
||||
// What the Settings section (#229) actually observed. A guard test
|
||||
// at the end of that section demands the full set, so a skipped or
|
||||
// silently shortened assertion reddens the run instead of shrinking
|
||||
|
||||
@@ -91,6 +91,17 @@ describe("displaySymbol", () => {
|
||||
expect(displaySymbol(exact)).toBe(exact);
|
||||
});
|
||||
|
||||
// An emoji outside the Basic Multilingual Plane is two UTF-16 units.
|
||||
// Cutting between them leaves half of one, which renders as U+FFFD.
|
||||
test("counts an emoji as one character and never cuts one in half", () => {
|
||||
expect(displaySymbol("🚀".repeat(MAX_SYMBOL_LENGTH))).toBe(
|
||||
"🚀".repeat(MAX_SYMBOL_LENGTH),
|
||||
);
|
||||
expect(displaySymbol("🚀".repeat(20))).toBe(
|
||||
"🚀".repeat(MAX_SYMBOL_LENGTH - 1) + "…",
|
||||
);
|
||||
});
|
||||
|
||||
test("substitutes a placeholder for an absent symbol", () => {
|
||||
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
|
||||
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);
|
||||
|
||||
Reference in New Issue
Block a user