2 Commits
Author SHA1 Message Date
sneak c5cd3fe330 test: drive the private key export screen end to end (closes #253)
e2e / e2e-chrome (push) Failing after 17s
e2e / e2e-firefox (push) Failing after 13s
check / check (push) Failing after 3h6m23s
The Chrome suite now drives the private key export screen as it drives the
recovery phrase screen: 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: leaving drops the address the screen was showing, so on an HD
wallet a late decrypt fails by itself and the liveness check would go
untested. Only the phrase screen's state reader now takes the screen's
name, and serves both; the wipe assertion takes the secret, as before. A
second open in one popup session throws (#460), so the cases reopen the
popup before it.

Model: opus-5-5
2026-10-05 09:27:04 +00:00
clawbot eec3e23099 chore: escape every value the views write as markup, and cut symbols on code points (closes #329)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
The token screen's decimals and holder count, the ETH price, every address
total and each balance row's USD value went into innerHTML unescaped, against
the rule at the top of src/popup/views/helpers.js. 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, not UTF-16 units, so the cut
never leaves half of an emoji, which rendered as U+FFFD.

explorerLink() was already removed on next.

Model: opus-5-5
2026-10-05 10:43:06 +02:00
10 changed files with 230 additions and 40 deletions
+22
View File
@@ -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
+1 -1
View File
@@ -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 || "&nbsp;";
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden");
+3 -3
View File
@@ -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 || "&nbsp;";
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// 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;
+1 -1
View File
@@ -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) || "&nbsp;"
? escapeHtml(formatUsd(amount * price)) || "&nbsp;"
: "&nbsp;";
// tokenId is a contract address out of the same explorer JSON, and it
// lands inside a quoted attribute.
+4 -3
View File
@@ -63,7 +63,7 @@ function renderTotalValue() {
const ethPrice = getPrice("ETH");
if (priceEl) {
priceEl.innerHTML = ethPrice
? formatUsd(ethPrice) + " USD/ETH"
? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
: "&nbsp;";
}
@@ -79,7 +79,8 @@ function renderTotalValue() {
el.textContent = ethStr + ethUsd;
if (subEl) {
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || "&nbsp;";
subEl.innerHTML =
escapeHtml(formatAddressTotal(getAddressValue(addr))) || "&nbsp;";
}
}
@@ -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 || "&nbsp;"}</div>`;
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || "&nbsp;"}</div>`;
html += balanceLinesForAddress(
addr,
state.trackedTokens,
+7 -2
View File
@@ -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 = {
+8
View File
@@ -194,6 +194,14 @@ describe("the wallet list on Home", () => {
clearPrices();
expect(walletListTotal(FULLY_PRICED)).toBe("&nbsp;");
});
// 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: &lt; $0.01");
expect(removalWarningTotal(tiny)).toBe("Total: &lt; $0.01");
});
});
describe("the balance warning on the address-removal confirmation", () => {
+7
View File
@@ -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("&lt; $0.01");
expect(html).not.toContain("< $0.01");
});
});
+166 -30
View File
@@ -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
+11
View File
@@ -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);