Compare commits

..
Author SHA1 Message Date
sneak 93b54c85e8 README: second rework after review
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Fix the 23 findings of the second review and the two leftovers from the
first: SiteApproval's connection lifetime, the stateSchema.js module line,
the flash line and title bar, the six network-bound screens, the token-only
0.000001 cutoff, the 12-character symbol cap, the time display, the native
currency labels, the truncateAmountNeverZero screens, the browser suite
rules, the build receipt contents, the External services list, the worker
memory exceptions, POPUP_ONLY_TYPES, the undefined terms, the comma lists,
the Rationale, Entrypoints and TODO sections, and the dropped crypto comment
rule. A fact pass then checked every statement about the code and every
absolute against each case, and a newcomer read removed repeats.

Model: fable-5-1
2026-11-02 02:15:46 +00:00
sneak a17563b5bc docs: rework the README after review
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Corrects every statement the review found false against the code, among
them which methods the background relays and for whom, where Connected
Sites live, which fields the stored-data check and the defaults cover,
what the balance lists round, and which address rows are plain text. Puts
the test-file citations under Tests, defines each technical word on first
use, turns every comma-chained list into bullets, and adds the coverage the
review asked for: valid amounts, the Firefox minimum version, the RPC
privacy note, the schema-version rule, the browser-suite rules.

Part of #672.

Model: fable-5-1
2026-11-02 01:13:18 +00:00
sneak 49801b6d5c docs: rewrite README.md sentence by sentence against the prose rules
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Every sentence is written for a newcomer to read once: one idea, subject
and verb first, under about 25 words, bullets instead of comma lists, no
tables, no project history. The order is what the extension is, what it
talks to, how to build and load it, the settings, the design, then
development and tests. Every statement was checked against the code on
next; the disagreements with the old text are listed in the PR.

Part of #672.

Model: fable-5-1
2026-11-02 00:44:57 +00:00
clawbot 3191577f73 test: make test fails when jest force-stops a worker (closes #711)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
When a test left a timer running, jest force-stopped the worker that ran the test and printed "A worker process has failed to exit gracefully", but the run still passed. Both test scripts in package.json now run jest through script/lib/jest.js. The helper runs jest inside its own process, so the terminal output is unchanged, and it exits 1 when jest writes that warning to stderr. Jest 30 has no option that fails the run on it. The eslint.config.js comment on script/lib/ now says those files run on plain node and are never bundled.

Model: opus-5-5
2026-11-02 01:44:22 +01:00
clawbot acb2577fec test: the test suite exits on its own, without --forceExit (closes #585)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Some tests left timers running when they ended, and --forceExit hid them; both test scripts drop it. Six background test files left the background's 20-second timer running while an approval was pending; each now stubs it for the whole file. Five popup test files left a flash message's two-second timer running; they now use fake timers, and a wait on real zero-delay timeouts became jest.advanceTimersByTimeAsync(0). Two ethers timers can still run for up to 250 ms after a test ends; they stop before jest checks for open handles.

Model: opus-5-5
2026-10-28 01:09:12 +01:00
clawbot a33a40a6fa build: say beside the install why yarn prints the workspaces warning (closes #595)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Yarn 1 prints "Workspaces can only be enabled in private projects." for each dependency whose package.json has a workspaces field without "private": true. Two do: eslint 10.8.1, and babel-plugin-istanbul 7.0.1, which jest pulls in. No update removes the field, and removing the warning would mean downgrading both below what this repo and jest require. A one-line comment beside the install in script/bootstrap and in the Dockerfile now names both packages and says why the warning stays; every Dockerfile install runs script/bootstrap, so one comment there is enough.

Model: opus-5-5
2026-10-28 00:28:27 +01:00
clawbot 5192419cac docs: README matches ConfirmTx's wrong-password message and fee-bound refusal (closes #610)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
README's ConfirmTx section quoted "Wrong password." for a wrong password; the screen says "That password is incorrect. Please try again.", and the README now quotes that. The other password screens describe their error without quoting it, and DeleteWallet already quoted it right. The fee-bound refusal no longer says the RPC endpoint always fills in the gas limit and fees at signing: for an ETH amount that Max filled in, they come from the screen's own fee estimate. The transition names `assertWithinCeilings()` and lists what it checks.

Model: opus-5-5
2026-10-28 00:05:20 +01:00
clawbot 41842b197e fix: a token lookup that answers after the user left AddToken no longer changes the screen (closes #603)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
When the contract lookup on AddToken answered, the popup went to AddressDetail even after the user had opened another screen. It now goes there only while AddToken shows and has not been opened again since Add was pressed; AddToken counts its openings as Send does. The token is still added as before unless the network changed. An answer, found or failed, from an earlier opening leaves the screen and the status line alone, so a later lookup's "Looking up token..." stays.

Judgement call: a user who went Back to AddressDetail during the lookup sees the new token once that screen is drawn again.

Model: opus-5-5
2026-10-27 23:16:10 +01:00
19 changed files with 1877 additions and 3512 deletions
+1
View File
@@ -10,6 +10,7 @@ WORKDIR /app
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
+1671 -3491
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -153,8 +153,7 @@ module.exports = [
}, },
}, },
// The helpers the script/ entrypoints call: plain node programs too, run // The files in script/lib/ run on plain node too, and are never bundled.
// from a shell script rather than from yarn, and never bundled.
{ {
files: ["script/lib/**/*.js"], files: ["script/lib/**/*.js"],
languageOptions: { languageOptions: {
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "jest --forceExit --maxWorkers=3", "test": "node script/lib/jest.js --maxWorkers=3",
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose", "test:verbose": "node script/lib/jest.js --maxWorkers=3 --verbose",
"build": "node build.js", "build": "node build.js",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fmt": "prettier --write .", "fmt": "prettier --write .",
+1
View File
@@ -118,6 +118,7 @@ ensure_yarn() {
fi fi
} }
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
install_js_deps() { install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
+40
View File
@@ -0,0 +1,40 @@
// Runs jest with the arguments it is given, and fails the run when jest had to
// force-stop the worker process that ran a test file. Jest itself only prints a
// warning when that happens, and its run still passes. The warning means a test
// left a timer or another open handle running. The test scripts in package.json
// run jest through this file.
//
// Jest runs inside this process and writes to the terminal directly, so its
// output looks the same as a plain jest run. This file only checks each write
// to stderr for the warning below.
"use strict";
// The start of the warning that jest prints on stderr after it force-stops a
// worker process.
const WORKER_WARNING = "A worker process has failed to exit gracefully";
let forceStopped = false;
const writeStderr = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...rest) => {
if (String(chunk).includes(WORKER_WARNING)) {
forceStopped = true;
}
return writeStderr(chunk, ...rest);
};
// Runs after jest has finished, as the process exits.
process.on("exit", () => {
if (forceStopped) {
console.error(
"script/lib/jest.js: failing the run, because jest force-stopped " +
"a worker process. A test left a timer or another open " +
"handle running.",
);
process.exitCode = 1;
}
});
// Jest's own command-line program. It reads its arguments from process.argv.
require("jest/bin/jest");
+12 -1
View File
@@ -5,7 +5,12 @@ const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
// Counts the times AddToken has opened. A contract lookup started before
// AddToken was last opened changes neither the screen nor the status line.
let addTokenScreenOpenings = 0;
function show() { function show() {
addTokenScreenOpenings++;
$("add-token-address").value = ""; $("add-token-address").value = "";
$("add-token-info").textContent = ""; $("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden"; $("add-token-info").style.visibility = "hidden";
@@ -55,6 +60,7 @@ function init(ctx) {
// The network the lookup asks, which Settings may switch away from // The network the lookup asks, which Settings may switch away from
// before it answers, as for a transaction in confirmTx.js. // before it answers, as for a transaction in confirmTx.js.
const chainId = currentNetwork().chainId; const chainId = currentNetwork().chainId;
const opening = addTokenScreenOpenings;
try { try {
const info = await lookupTokenInfo( const info = await lookupTokenInfo(
contractAddr, contractAddr,
@@ -80,11 +86,15 @@ function init(ctx) {
}); });
await saveState(); await saveState();
ctx.doRefreshAndRender(); ctx.doRefreshAndRender();
// If the lookup answers after AddToken was opened again, the
// token is still added, but the screen and the status line stay
// as they are.
if (addTokenScreenOpenings !== opening) return;
// The lookup is over, so a later Back onto AddToken shows no // The lookup is over, so a later Back onto AddToken shows no
// status. // status.
infoEl.textContent = ""; infoEl.textContent = "";
infoEl.style.visibility = "hidden"; infoEl.style.visibility = "hidden";
// If the user left AddToken while the lookup ran, the token is // If the lookup answers while another screen shows, the token is
// still added but the screen does not change. // still added but the screen does not change.
if (state.currentView !== "add-token") return; if (state.currentView !== "add-token") return;
// Pop the stack (back to address detail) and re-render it // Pop the stack (back to address detail) and re-render it
@@ -107,6 +117,7 @@ function init(ctx) {
? detail ? detail
: "Could not add the token.", : "Could not add the token.",
); );
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = ""; infoEl.textContent = "";
infoEl.style.visibility = "hidden"; infoEl.style.visibility = "hidden";
} }
+42 -1
View File
@@ -18,6 +18,9 @@ const TOKEN = {
name: "Test Token", name: "Test Token",
}; };
// A second contract address, typed after AddToken was opened again.
const OTHER_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
afterEach(() => { afterEach(() => {
cleanupPopup(); cleanupPopup();
}); });
@@ -72,7 +75,7 @@ test("an answer after the user opened Settings adds the token and Settings stays
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]); expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
}); });
test("Back onto AddToken after that answer shows no lookup status", async () => { test("Back from Settings onto AddToken after the lookup answered shows no lookup status", async () => {
const boot = await openAddToken(); const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot); const answerLookup = addWithPendingLookup(boot);
expect(boot.text("add-token-info")).toBe("Looking up token..."); expect(boot.text("add-token-info")).toBe("Looking up token...");
@@ -85,6 +88,44 @@ test("Back onto AddToken after that answer shows no lookup status", async () =>
expect(boot.node("add-token-info").style.visibility).toBe("hidden"); expect(boot.node("add-token-info").style.visibility).toBe("hidden");
}); });
test("an answer after AddToken was opened again adds the token and leaves AddToken and its status line as they are", async () => {
const boot = await openAddToken();
const answerFirstLookup = addWithPendingLookup(boot);
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
expect(boot.visibleViews()).toEqual(["add-token"]);
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
await answerFirstLookup();
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.text("add-token-info")).toBe("Looking up token...");
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("a lookup that fails after AddToken was opened again leaves the status line as it is", async () => {
const boot = await openAddToken();
const { lookupTokenInfo } = require("../src/shared/balances");
let failFirstLookup;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((_, reject) => {
failFirstLookup = reject;
}),
);
const firstAdd = boot.click("btn-add-token-confirm");
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
failFirstLookup(new Error("lookup failed"));
await firstAdd;
expect(boot.text("add-token-info")).toBe("Looking up token...");
});
test("an answer while AddToken still shows goes to AddressDetail with the token listed", async () => { test("an answer while AddToken still shows goes to AddressDetail with the token listed", async () => {
const boot = await openAddToken(); const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot); const answerLookup = addWithPendingLookup(boot);
+6 -1
View File
@@ -161,7 +161,7 @@ function click(element) {
// show() starts the fee estimate without awaiting it; this lets it settle. // show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() { async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
} }
// ------------------------------------------------------------ harness // ------------------------------------------------------------ harness
@@ -188,7 +188,11 @@ let sent;
// handler to the buttons it drew, as the browser's fresh list would have. // handler to the buttons it drew, as the browser's fresh list would have.
let plusButton; let plusButton;
// showFlash() starts a timer that clears the message one or two seconds later.
// Each test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers();
approvalDetails = null; approvalDetails = null;
sent = []; sent = [];
mockNode.broadcast = []; mockNode.broadcast = [];
@@ -239,6 +243,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
+11 -12
View File
@@ -537,7 +537,18 @@ async function settleIncludingRejections() {
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
jest.resetModules(); jest.resetModules();
}); });
@@ -1883,18 +1894,6 @@ describe("a closed approval window leaves the request pending", () => {
return bg.actionPopups[bg.actionPopups.length - 1]; return bg.actionPopups[bg.actionPopups.length - 1];
} }
// The timer that keeps the worker running while an approval is pending is
// stubbed, so no test here leaves that timer running when it ends.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
});
test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => { test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => {
const bg = loadBackground(); const bg = loadBackground();
const pending = bg.requestTx(); const pending = bg.requestTx();
+11
View File
@@ -103,7 +103,18 @@ function deferred() {
return { promise, resolve }; return { promise, resolve };
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
+11
View File
@@ -56,7 +56,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); for (let i = 0; i < 50; i++) await Promise.resolve();
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
+11
View File
@@ -64,7 +64,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); for (let i = 0; i < 50; i++) await Promise.resolve();
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
+11
View File
@@ -76,7 +76,18 @@ async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve(); for (let i = 0; i < 60; i++) await Promise.resolve();
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
+11
View File
@@ -57,7 +57,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); for (let i = 0; i < 50; i++) await Promise.resolve();
} }
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
jest.resetModules(); jest.resetModules();
}); });
+11
View File
@@ -102,6 +102,17 @@ function makeDocument() {
// ------------------------------------------------------------ harness // ------------------------------------------------------------ harness
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
function wallet(name, secret, addresses) { function wallet(name, secret, addresses) {
return { return {
type: "hd", type: "hd",
+9 -1
View File
@@ -131,10 +131,14 @@ async function review(amount, token) {
async function confirm(amount, token) { async function confirm(amount, token) {
const txInfo = await review("0.5", token); const txInfo = await review("0.5", token);
confirmTx.show({ ...txInfo, amount }); confirmTx.show({ ...txInfo, amount });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
} }
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers();
elements.clear(); elements.clear();
state.wallets = [ state.wallets = [
{ {
@@ -167,6 +171,10 @@ beforeEach(() => {
state.currentView = null; state.currentView = null;
}); });
afterEach(() => {
jest.useRealTimers();
});
describe.each([ describe.each([
["1e-3", "ETH"], ["1e-3", "ETH"],
["1e-3", "TOK"], ["1e-3", "TOK"],
+9 -1
View File
@@ -277,14 +277,18 @@ async function review() {
// show() starts the fee estimate without awaiting it; this lets it settle. // show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() { async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
} }
function canSend() { function canSend() {
return !el("btn-confirm-send").disabled; return !el("btn-confirm-send").disabled;
} }
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers();
elements.clear(); elements.clear();
log.errorf.mockClear(); log.errorf.mockClear();
pageClosed = new AbortController(); pageClosed = new AbortController();
@@ -297,6 +301,10 @@ beforeEach(() => {
mockNode.broadcast = []; mockNode.broadcast = [];
}); });
afterEach(() => {
jest.useRealTimers();
});
describe("Max on an ETH send", () => { describe("Max on an ETH send", () => {
test("fills in the exact balance minus the fee reserve", async () => { test("fills in the exact balance minus the fee reserve", async () => {
await refreshWith(BALANCE_WEI); await refreshWith(BALANCE_WEI);
+6
View File
@@ -357,6 +357,11 @@ describe("every way to a signature or the private key refuses a defective wallet
}); });
beforeEach(() => { beforeEach(() => {
// showFlash() starts a timer that clears the message two seconds
// later. Each test here runs on fake timers and drops them in
// afterEach, so that timer never outlives the test.
jest.useFakeTimers();
const broken = brokenXprvWallet(); const broken = brokenXprvWallet();
// A balance, so that no Send button's zero-balance refusal can stand // A balance, so that no Send button's zero-balance refusal can stand
// in for the defect check. // in for the defect check.
@@ -398,6 +403,7 @@ describe("every way to a signature or the private key refuses a defective wallet
}); });
afterEach(() => { afterEach(() => {
jest.useRealTimers();
state.wallets = []; state.wallets = [];
state.activeAddress = null; state.activeAddress = null;
state.selectedWallet = null; state.selectedWallet = null;