Files
AutistMask/tests/passwordMessages.test.js
clawbot 514d025c56
All checks were successful
check / check (push) Successful in 30s
fix: one wording for an empty password field on every screen (closes #265)
The private key export screen answered an empty password field with "Password
is required." where the other five screens that ask for the password say
"Please enter your password." Same condition, two sentences, and a user hitting
both had no reason to think they meant the same thing. The majority wording
wins; the export screen now says it too.

Strings only. Every screen still refuses to proceed on an empty field exactly
as before.

tests/passwordMessages.test.js already pinned the rejected-password sentence
per decryptWithPassword call site; it now pins the empty-field sentence the
same way, walking each decrypt's password variable back to the guard that
rejects it blank and requiring the prose there to be the canonical sentence and
nothing else. Anchoring on the decrypt keeps the wallet-creation screen out of
the set, where an empty field means a password being chosen rather than
entered -- a different condition with its own wording.

Every error container measured at a 360px viewport in the pinned Playwright
container against the built popup. The export screen's container holds at 20px
with the section below it at the same offset for the empty reserved state, the
old string and the new one.
2026-08-17 06:32:57 +00:00

300 lines
12 KiB
JavaScript

// One wording for one condition (issues #172 and #265).
//
// Every screen that asks for the password decrypts the vault itself, and
// each one used to write its own sentence for the same failure: the send
// confirmation and the delete-wallet confirmation said "Wrong password."
// (a fragment, which RULES.md Language & Labeling forbids), the reveal
// screens said "That password is not correct.", and the two dApp approval
// paths said "That password is incorrect." A user hitting two of those
// minutes apart had no way to tell whether the wallet meant the same
// thing.
//
// This scans the source rather than driving six views, because the
// invariant is about the set of call sites and not about any one of them:
// a seventh screen that decrypts the vault has to join the set, and a
// DOM test per view cannot notice one that was never written.
//
// The assertions are per CALL SITE, not per file. approval.js decrypts in
// two places and is where the divergence came from; a per-file check that
// only asks whether the canonical sentence appears somewhere in the file
// passes while one of those two says something else entirely. So each
// call site is read back to its own catch handler and the prose that
// handler shows the user must be the canonical sentence and nothing else
// — which fails on a novel wording, not only on a known-superseded one.
//
// The empty-password condition (#265) is pinned the same way and off the
// same call sites: the private key export screen said "Password is
// required." where the other five said "Please enter your password." Each
// decrypt's password variable is walked back to the guard that rejects it
// when blank, and the prose that guard shows must be the canonical
// sentence. Anchoring on the decrypt keeps the wallet-creation screen out
// of the set: an empty field there is a password being CHOSEN, a
// different condition with its own wording.
const fs = require("fs");
const path = require("path");
const SRC = path.join(__dirname, "..", "src");
const CANONICAL = "That password is incorrect. Please try again.";
const CANONICAL_EMPTY = "Please enter your password.";
// Wordings this repo has actually shipped for the same condition. This is
// a secondary, whole-file sweep for stragglers outside a decrypt handler;
// divergence at a call site is caught by the exact-match assertion, which
// needs no list of phrasings to guess at.
const SUPERSEDED = [
"Wrong password.",
"That password is not correct. Please try again.",
"Password is required.",
];
function jsFilesUnder(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return jsFilesUnder(full);
return entry.name.endsWith(".js") ? [full] : [];
});
}
// Blank out the interior of every comment and string literal, keeping the
// offsets and line breaks, so braces can be counted without a quote or a
// commented-out block throwing the count off. The literals are returned
// alongside with the offset of their opening quote, which is how a
// message is later attributed to the handler it sits in.
function scan(source) {
const masked = source.split("");
const strings = [];
const blank = (from, to) => {
for (let k = from; k < to; k++) if (masked[k] !== "\n") masked[k] = " ";
};
let i = 0;
while (i < source.length) {
const two = source.slice(i, i + 2);
if (two === "//") {
const nl = source.indexOf("\n", i);
const stop = nl === -1 ? source.length : nl;
blank(i, stop);
i = stop;
} else if (two === "/*") {
const close = source.indexOf("*/", i + 2);
const stop = close === -1 ? source.length : close + 2;
blank(i, stop);
i = stop;
} else if (
source[i] === '"' ||
source[i] === "'" ||
source[i] === "`"
) {
const quote = source[i];
let j = i + 1;
let value = "";
while (j < source.length && source[j] !== quote) {
if (source[j] === "\\") {
value += source[j + 1];
j += 2;
continue;
}
value += source[j];
j += 1;
}
blank(i + 1, j);
strings.push({ offset: i, value });
i = j + 1;
} else {
i += 1;
}
}
return { masked: masked.join(""), strings };
}
// Offset of the `{` that opens the block containing `at`, or -1.
function enclosingBlockStart(masked, at) {
let depth = 0;
for (let i = at; i >= 0; i--) {
if (masked[i] === "}") depth += 1;
else if (masked[i] === "{") {
if (depth === 0) return i;
depth -= 1;
}
}
return -1;
}
// Offset just past the `}` matching the `{` at `open`.
function blockEnd(masked, open) {
let depth = 0;
for (let i = open; i < masked.length; i++) {
if (masked[i] === "{") depth += 1;
else if (masked[i] === "}") {
depth -= 1;
if (depth === 0) return i + 1;
}
}
throw new Error("unterminated block");
}
// The catch handler guarding a given decryptWithPassword call: walk out to
// the try block the call sits in, then take the catch that follows it.
function handlerSpan(masked, callOffset, label) {
const tryOpen = enclosingBlockStart(masked, callOffset);
if (tryOpen === -1 || !/\btry\s*$/.test(masked.slice(0, tryOpen)))
throw new Error(`${label}: the decrypt is not inside a try block`);
const rest = masked.slice(blockEnd(masked, tryOpen));
const catchMatch = /^\s*catch\s*(\([^)]*\)\s*)?\{/.exec(rest);
if (!catchMatch)
throw new Error(`${label}: the decrypt's try block has no catch`);
const catchOpen = blockEnd(masked, tryOpen) + catchMatch[0].length - 1;
return [catchOpen, blockEnd(masked, catchOpen)];
}
// The prose the handler puts in front of the user. Element ids, class
// names and visibility keywords are single words; a sentence has a space
// in it, and that is the whole distinction needed here.
function handlerMessages(file, callOffset, label) {
const { masked, strings } = scan(fs.readFileSync(file, "utf8"));
const [from, to] = handlerSpan(masked, callOffset, label);
return strings
.filter((s) => s.offset >= from && s.offset < to)
.map((s) => s.value)
.filter((v) => v.includes(" "));
}
// The identifier a decrypt call passes as its password, which is what the
// empty-field guard for that screen tests.
function passwordArg(masked, callOffset, label) {
const open = callOffset + "decryptWithPassword".length;
const args = [];
let depth = 0;
let start = open + 1;
for (let i = open; i < masked.length; i++) {
const c = masked[i];
if (c === "(" || c === "[" || c === "{") depth += 1;
else if (c === ")" || c === "]" || c === "}") {
depth -= 1;
if (depth === 0) {
args.push(masked.slice(start, i));
break;
}
} else if (c === "," && depth === 1) {
args.push(masked.slice(start, i));
start = i + 1;
}
}
const arg = (args[1] ?? "").trim();
if (!/^[A-Za-z_$][\w$]*$/.test(arg))
throw new Error(`${label}: password argument is not a name: ${arg}`);
return arg;
}
// Innermost block enclosing the decrypt that also declares its password
// variable — the handler the screen's submit button runs, which is where
// the empty-field guard lives.
function declaringBlock(masked, callOffset, ident, label) {
const declared = new RegExp(`\\b(?:const|let|var)\\s+${ident}\\s*=`);
let at = callOffset;
for (;;) {
const open = enclosingBlockStart(masked, at);
if (open === -1) throw new Error(`${label}: nothing declares ${ident}`);
const end = blockEnd(masked, open);
if (declared.test(masked.slice(open, end))) return [open, end];
at = open - 1;
}
}
// The prose the empty-field guard puts in front of the user. Exactly one
// guard per handler is required: two would mean the condition is answered
// in more than one place and this would be pinning only one of them.
function emptyGuardMessages(file, callOffset, label) {
const { masked, strings } = scan(fs.readFileSync(file, "utf8"));
const ident = passwordArg(masked, callOffset, label);
const [from, to] = declaringBlock(masked, callOffset, ident, label);
const guard = new RegExp(`if\\s*\\(\\s*!\\s*${ident}\\s*\\)\\s*\\{`, "g");
const opens = [];
let m;
while ((m = guard.exec(masked.slice(from, to))) !== null)
opens.push(from + m.index + m[0].length - 1);
if (opens.length !== 1)
throw new Error(
`${label}: expected one empty-${ident} guard, found ${opens.length}`,
);
const close = blockEnd(masked, opens[0]);
return strings
.filter((s) => s.offset >= opens[0] && s.offset < close)
.map((s) => s.value)
.filter((v) => v.includes(" "));
}
// The call sites are found, not listed: the file layout moves (the private
// key export was in addressDetail.js when #172 was filed and is its own
// view now), and a hardcoded list would quietly stop covering a screen it
// no longer names.
function callSites() {
const sites = [];
for (const file of jsFilesUnder(SRC)) {
if (file === path.join(SRC, "shared", "vault.js")) continue;
const { masked } = scan(fs.readFileSync(file, "utf8"));
const rel = path.relative(SRC, file).split(path.sep).join("/");
let n = 0;
let at = masked.indexOf("decryptWithPassword(");
while (at !== -1) {
n += 1;
sites.push({ file, rel, offset: at, label: `${rel} #${n}` });
at = masked.indexOf("decryptWithPassword(", at + 1);
}
}
return sites.sort((a, b) => a.label.localeCompare(b.label));
}
describe("password failure messages", () => {
const sites = callSites();
const files = [...new Set(sites.map((s) => s.file))].sort();
test("the call sites are found where they are expected", () => {
const counts = {};
for (const site of sites)
counts[site.rel] = (counts[site.rel] ?? 0) + 1;
expect(counts).toEqual({
"popup/views/approval.js": 2,
"popup/views/confirmTx.js": 1,
"popup/views/deleteWallet.js": 1,
"popup/views/exportPrivkey.js": 1,
"popup/views/showPhrase.js": 1,
});
});
test("the canonical messages are full sentences", () => {
expect(CANONICAL).toMatch(/^[A-Z][^]*\.$/);
expect(CANONICAL_EMPTY).toMatch(/^[A-Z][^]*\.$/);
});
// Exact equality, per call site: a message that is merely different
// rather than known-obsolete fails here too, which a scan for historic
// wordings cannot do.
test.each(sites.map((s) => [s.label, s]))(
"%s answers a rejected password with the canonical sentence",
(label, site) => {
expect(handlerMessages(site.file, site.offset, label)).toEqual([
CANONICAL,
]);
},
);
test.each(sites.map((s) => [s.label, s]))(
"%s answers an empty password field with the canonical sentence",
(label, site) => {
expect(emptyGuardMessages(site.file, site.offset, label)).toEqual([
CANONICAL_EMPTY,
]);
},
);
test.each(files.map((f) => [path.relative(SRC, f), f]))(
"%s carries no superseded wording",
(_rel, file) => {
const source = fs.readFileSync(file, "utf8");
for (const old of SUPERSEDED) expect(source).not.toContain(old);
},
);
});