Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdbe999c05 |
@@ -1933,19 +1933,10 @@ view would leave a wallet one click from deletion.
|
|||||||
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
||||||
- Type: "Personal message" or "Typed data (EIP-712)"
|
- Type: "Personal message" or "Typed data (EIP-712)"
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
|
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
||||||
decode to as UTF-8, laid out left to right in the order of the bytes that
|
message fields (EIP-712 typed data). The primary type shown is the one
|
||||||
are signed, right-to-left characters included. Each control character,
|
ethers signs, derived from the typed data's `types`, not the type the site
|
||||||
each line or paragraph separator (U+2028, U+2029; left in the text, a
|
states.
|
||||||
paragraph separator would end that layout for the text after it), and each
|
|
||||||
character that paints nothing (format characters such as zero-width and
|
|
||||||
bidirectional ones, default-ignorable characters such as variation
|
|
||||||
selectors and Hangul fillers, and DELETE), is shown as a bordered `U+XXXX`
|
|
||||||
mark instead of acting on the text; a line feed is shown as a line break.
|
|
||||||
Bytes that are not UTF-8 are shown as "This message is not text." For
|
|
||||||
typed data, formatted domain/type/message fields (EIP-712). The primary
|
|
||||||
type shown is the one ethers signs, derived from the typed data's `types`,
|
|
||||||
not the type the site states.
|
|
||||||
- Token permission warning, at the top of the message (typed data whose
|
- Token permission warning, at the top of the message (typed data whose
|
||||||
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
|
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
|
||||||
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
|
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
|
||||||
@@ -1957,20 +1948,12 @@ view would leave a wallet one click from deletion.
|
|||||||
domain's `verifyingContract`; any those fields do not give is shown as
|
domain's `verifyingContract`; any those fields do not give is shown as
|
||||||
`Unknown`, and the domain, type and message lines still follow. Only typed
|
`Unknown`, and the domain, type and message lines still follow. Only typed
|
||||||
data that cannot be read at all is shown as raw text.
|
data that cannot be read at all is shown as raw text.
|
||||||
- Raw data (`personal_sign` and `eth_sign`): the message's hex exactly as
|
|
||||||
the site sent it. The bytes it encodes are what is signed, as an EIP-191
|
|
||||||
personal message.
|
|
||||||
- Password input and an error line
|
- Password input and an error line
|
||||||
- "Sign" / "Reject" buttons
|
- "Sign" / "Reject" buttons
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Typed data that states no primary type, or one other than the type it
|
- Typed data that states no primary type, or one other than the type it
|
||||||
would be signed as, or that cannot be read → shown with the error line
|
would be signed as, or that cannot be read → shown with the error line
|
||||||
saying so and "Sign" disabled; only "Reject" remains
|
saying so and "Sign" disabled; only "Reject" remains
|
||||||
- A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and
|
|
||||||
an even number of hex digits, the form ethers' `getBytes` reads when
|
|
||||||
signing) → shown as plain text, with the error line "This message is plain
|
|
||||||
text, not hex, so it cannot be signed." and "Sign" disabled; signing takes
|
|
||||||
the bytes from the hex, so such a message has none to sign
|
|
||||||
- "Sign" (correct password) → signs locally → closes popup (returns
|
- "Sign" (correct password) → signs locally → closes popup (returns
|
||||||
signature)
|
signature)
|
||||||
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
||||||
@@ -1989,12 +1972,10 @@ view would leave a wallet one click from deletion.
|
|||||||
reaches the network ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)).
|
reaches the network ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)).
|
||||||
A save that fails for any other reason, such as a storage read or write that
|
A save that fails for any other reason, such as a storage read or write that
|
||||||
errors, gets the "NOT SAVED" banner instead and leaves the screen as it is.
|
errors, gets the "NOT SAVED" banner instead and leaves the screen as it is.
|
||||||
The screen it replaces is left as any navigation leaves it, so a revealed
|
Once up, the screen stays until the popup closes or the record is erased:
|
||||||
phrase or key, or a typed password, is wiped. Once up, the screen stays until
|
nothing in AutistMask writes over a record that fails the check, so it cannot
|
||||||
the popup closes or the record is erased: work still running in the popup,
|
become readable again underneath. It is the only screen that never appears
|
||||||
such as a transaction wait, cannot replace it, and nothing in AutistMask
|
during ordinary use.
|
||||||
writes over a record that fails the check, so it cannot become readable again
|
|
||||||
underneath. It is the only screen that never appears during ordinary use.
|
|
||||||
- **Why it exists**: a record the wallet cannot read used to render nothing — no
|
- **Why it exists**: a record the wallet cannot read used to render nothing — no
|
||||||
view, no message, no control — while every dApp call answered a generic
|
view, no message, no control — while every dApp call answered a generic
|
||||||
internal error, and no reset or wipe control existed anywhere in the product.
|
internal error, and no reset or wipe control existed anywhere in the product.
|
||||||
@@ -2021,20 +2002,16 @@ view would leave a wallet one click from deletion.
|
|||||||
Nothing was erased." on the error line
|
Nothing was erased." on the error line
|
||||||
- **No other control is reachable.** The Settings gear is hidden while this
|
- **No other control is reachable.** The Settings gear is hidden while this
|
||||||
screen is up, because every screen behind it renders from the profile that
|
screen is up, because every screen behind it renders from the profile that
|
||||||
could not be read. At open `showView()` is not used to raise it for the same
|
could not be read, and `showView()` is not used to raise it for the same
|
||||||
reason — it reads and writes the state singleton. Under an open popup it is
|
reason — it reads and writes the state singleton.
|
||||||
also passed to `showView()`, which runs the replaced screen's cleanup and
|
|
||||||
records it as the current view, and `showView()` shows nothing else after
|
|
||||||
that.
|
|
||||||
- **Both controls are required.** An export with no reset leaves the user
|
- **Both controls are required.** An export with no reset leaves the user
|
||||||
looking at a broken profile with no way to use the wallet again; a reset with
|
looking at a broken profile with no way to use the wallet again; a reset with
|
||||||
no export destroys the only copy of a record that may hold recoverable key
|
no export destroys the only copy of a record that may hold recoverable key
|
||||||
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
|
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
|
||||||
and for the same reason: there is no password to gate this with, since there
|
and for the same reason: there is no password to gate this with, since there
|
||||||
is no profile to check one against.
|
is no profile to check one against.
|
||||||
- Not in `RESTORABLE_VIEWS`, and not persisted as the current view: at open
|
- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
|
||||||
nothing on this path writes state at all, and under an open popup the check
|
nothing on this path writes state at all.
|
||||||
that raised the screen refuses the save that would record it.
|
|
||||||
|
|
||||||
### External Services
|
### External Services
|
||||||
|
|
||||||
|
|||||||
@@ -51,22 +51,10 @@ but the review is broader than any of them.
|
|||||||
the last good profile, with the "NOT SAVED" banner at most, until reopened.
|
the last good profile, with the "NOT SAVED" banner at most, until reopened.
|
||||||
Every save already ran the check the popup runs at open; a save that fails
|
Every save already ran the check the popup runs at open; a save that fails
|
||||||
that check now raises the recovery screen and stops the ten-second refresh, so
|
that check now raises the recovery screen and stops the ten-second refresh, so
|
||||||
the popup finds the record at the next navigation or refresh. The screen it
|
the popup finds the record at the next navigation or refresh. Any other failed
|
||||||
replaces is left as any navigation leaves it, so a revealed phrase or key or a
|
save still gets the banner and leaves the screen alone. The recovery screen
|
||||||
typed password is wiped. Once up, nothing else can replace it, and a later
|
ignores a second request to show it, so a later save does not clear an export
|
||||||
save or a transaction wait that ends does not clear an export or a typed
|
or a typed confirmation.
|
||||||
confirmation. Any other failed save still gets the banner and leaves the
|
|
||||||
screen alone.
|
|
||||||
- 2026-10-04: The signature screen shows a personal message as the bytes that
|
|
||||||
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
|
|
||||||
showed only the decoded text, with bidirectional and zero-width characters
|
|
||||||
acting on it, so a site could make the message read differently from what is
|
|
||||||
signed, and a message that was not hex was shown as NUL characters. The hex is
|
|
||||||
now shown as "Raw data" alongside the decoded text, the text is laid out left
|
|
||||||
to right in byte order, control characters, line and paragraph separators and
|
|
||||||
characters that paint nothing are shown as `U+XXXX` marks, and a message that
|
|
||||||
is not hex by the rule signing reads it with is shown as plain text with
|
|
||||||
"Sign" disabled, since such a message has no bytes to sign.
|
|
||||||
- 2026-10-04: A token that reports more than 80 decimal places has no known
|
- 2026-10-04: A token that reports more than 80 decimal places has no known
|
||||||
scale ([#350](https://git.eeqj.de/sneak/AutistMask/issues/350)). The shared
|
scale ([#350](https://git.eeqj.de/sneak/AutistMask/issues/350)). The shared
|
||||||
scale check `toDecimals()` accepted any `uint8`, but `formatUnits()` throws
|
scale check `toDecimals()` accepted any `uint8`, but `formatUnits()` throws
|
||||||
|
|||||||
@@ -1698,15 +1698,6 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="approve-sign-hex-section" class="mb-3 hidden">
|
|
||||||
<div class="text-xs text-muted mb-1">Raw data</div>
|
|
||||||
<div
|
|
||||||
id="approve-sign-hex"
|
|
||||||
class="text-xs break-all"
|
|
||||||
style="max-height: 6rem; overflow-y: auto"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
+3
-9
@@ -164,19 +164,13 @@ async function init() {
|
|||||||
// check loadState() runs below. So a record that becomes unreadable while
|
// check loadState() runs below. So a record that becomes unreadable while
|
||||||
// the popup is open is found by the next save, a navigation or the
|
// the popup is open is found by the next save, a navigation or the
|
||||||
// ten-second refresh, and gets the screen it would get at open
|
// ten-second refresh, and gets the screen it would get at open
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/373). The recovery screen
|
// (https://git.eeqj.de/sneak/AutistMask/issues/373). Any other failed save
|
||||||
// is then also passed to showView(), which runs the leave cleanup of the
|
// is a read or write that failed, and gets the banner without changing
|
||||||
// screen it replaces, so a phrase, key or password on it is wiped, and
|
// the screen.
|
||||||
// records it as the current view, after which showView() shows nothing
|
|
||||||
// else. The save showView() fires fails too and comes back here, where
|
|
||||||
// both calls see the screen already up and do nothing. Any other failed
|
|
||||||
// save is a read or write that failed, and gets the banner without
|
|
||||||
// changing the screen.
|
|
||||||
onSaveFailure((e) => {
|
onSaveFailure((e) => {
|
||||||
if (e instanceof StateUnusableError) {
|
if (e instanceof StateUnusableError) {
|
||||||
clearInterval(refreshTimer);
|
clearInterval(refreshTimer);
|
||||||
stateRecovery.show(e);
|
stateRecovery.show(e);
|
||||||
showView("state-recovery");
|
|
||||||
} else {
|
} else {
|
||||||
showSaveFailureBanner(e);
|
showSaveFailureBanner(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,13 +64,3 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A personal message on the signature screen is laid out left to right in
|
|
||||||
* the order of its bytes. Without this, right-to-left characters in it move
|
|
||||||
* the characters around them: `5`, U+05C3, `00` would read as `500`
|
|
||||||
* followed by U+05C3. A paragraph separator (U+2029) ends this layout for
|
|
||||||
* the text after it, so src/popup/views/approval.js shows one as a mark. */
|
|
||||||
.am-byte-order {
|
|
||||||
direction: ltr;
|
|
||||||
unicode-bidi: bidi-override;
|
|
||||||
}
|
|
||||||
|
|||||||
+11
-57
@@ -26,7 +26,6 @@ const {
|
|||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||||
const { ERC20_ABI } = require("../../shared/constants");
|
const { ERC20_ABI } = require("../../shared/constants");
|
||||||
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
|
||||||
const {
|
const {
|
||||||
resolveTokenDecimals,
|
resolveTokenDecimals,
|
||||||
resolveTokenSymbol,
|
resolveTokenSymbol,
|
||||||
@@ -381,48 +380,20 @@ function showTxApproval(details) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether a personal message is hex by the rule signing reads it with:
|
|
||||||
// signing takes getBytes(message), which throws on anything else.
|
|
||||||
function isHexMessage(message) {
|
|
||||||
try {
|
|
||||||
getBytes(message);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The text the hex message's bytes decode to as UTF-8, or null when they are
|
|
||||||
// not UTF-8. The caller has checked that the message is hex.
|
|
||||||
function decodeHexMessage(hex) {
|
function decodeHexMessage(hex) {
|
||||||
try {
|
try {
|
||||||
return toUtf8String(getBytes(hex));
|
const bytes = Uint8Array.from(
|
||||||
|
hex
|
||||||
|
.slice(2)
|
||||||
|
.match(/.{1,2}/g)
|
||||||
|
.map((b) => parseInt(b, 16)),
|
||||||
|
);
|
||||||
|
return toUtf8String(bytes);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A character shown as a bordered U+XXXX mark.
|
|
||||||
function codePointMark(c) {
|
|
||||||
const code = c.codePointAt(0).toString(16).toUpperCase();
|
|
||||||
return `<span class="border border-border">U+${code.padStart(4, "0")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The text as HTML, with each character that paints nothing (zero-width and
|
|
||||||
// bidirectional characters, variation selectors and Hangul fillers among
|
|
||||||
// them), each control character and each line or paragraph separator
|
|
||||||
// (U+2028, U+2029) shown as a mark. A line feed is shown as a line break.
|
|
||||||
// Left in the text, a paragraph separator would end the byte-order layout
|
|
||||||
// for everything after it. The marks are plain ASCII, so the second pass
|
|
||||||
// leaves them be.
|
|
||||||
function markInvisibleCharacters(text) {
|
|
||||||
return escapeHtml(text)
|
|
||||||
.replace(INVISIBLE_CHARACTERS, codePointMark)
|
|
||||||
.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, (c) =>
|
|
||||||
c === "\n" ? "<br>" : codePointMark(c),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The type ethers will sign typed data as. ethers does not read the page's
|
// The type ethers will sign typed data as. ethers does not read the page's
|
||||||
// `primaryType`: it takes the one struct in `types` that no other struct
|
// `primaryType`: it takes the one struct in `types` that no other struct
|
||||||
// refers to. Throws when the types name no such single struct, which ethers
|
// refers to. Throws when the types name no such single struct, which ethers
|
||||||
@@ -686,33 +657,15 @@ function showSignApproval(details) {
|
|||||||
? "Typed data (EIP-712)"
|
? "Typed data (EIP-712)"
|
||||||
: "Personal message";
|
: "Personal message";
|
||||||
|
|
||||||
// A personal message is signed as the bytes its hex encodes, so the hex
|
|
||||||
// is shown as well as any text it decodes to, and that text is laid out
|
|
||||||
// left to right in the order of its bytes. Signing reads the bytes from
|
|
||||||
// the hex, so a message that is not hex cannot be signed: it is shown as
|
|
||||||
// the text it is, and refused.
|
|
||||||
let refusal = null;
|
|
||||||
$("approve-sign-hex-section").classList.add("hidden");
|
|
||||||
$("approve-sign-message").classList.toggle("am-byte-order", !isTyped);
|
|
||||||
if (isTyped) {
|
if (isTyped) {
|
||||||
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
||||||
refusal = typedDataRefusal(sp);
|
} else {
|
||||||
} else if (isHexMessage(sp.message)) {
|
|
||||||
const decoded = decodeHexMessage(sp.message);
|
const decoded = decodeHexMessage(sp.message);
|
||||||
if (decoded !== null) {
|
if (decoded !== null) {
|
||||||
$("approve-sign-message").innerHTML =
|
$("approve-sign-message").textContent = decoded;
|
||||||
markInvisibleCharacters(decoded);
|
|
||||||
} else {
|
} else {
|
||||||
$("approve-sign-message").textContent = "This message is not text.";
|
$("approve-sign-message").textContent = sp.message;
|
||||||
}
|
}
|
||||||
$("approve-sign-hex").textContent = sp.message;
|
|
||||||
$("approve-sign-hex-section").classList.remove("hidden");
|
|
||||||
} else {
|
|
||||||
$("approve-sign-message").innerHTML = markInvisibleCharacters(
|
|
||||||
sp.message,
|
|
||||||
);
|
|
||||||
refusal =
|
|
||||||
"This message is plain text, not hex, so it cannot be signed.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display danger warning for eth_sign (raw hash signing)
|
// Display danger warning for eth_sign (raw hash signing)
|
||||||
@@ -734,6 +687,7 @@ function showSignApproval(details) {
|
|||||||
|
|
||||||
showView("approve-sign");
|
showView("approve-sign");
|
||||||
attachCopyHandlers("view-approve-sign");
|
attachCopyHandlers("view-approve-sign");
|
||||||
|
const refusal = typedDataRefusal(sp);
|
||||||
if (refusal) {
|
if (refusal) {
|
||||||
showError("approve-sign-error", refusal);
|
showError("approve-sign-error", refusal);
|
||||||
$("btn-approve-sign").disabled = true;
|
$("btn-approve-sign").disabled = true;
|
||||||
|
|||||||
@@ -52,10 +52,9 @@ const VIEWS = [
|
|||||||
"export-privkey",
|
"export-privkey",
|
||||||
"show-phrase",
|
"show-phrase",
|
||||||
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
||||||
// cannot be read. At open it is not reached through showView(), since the
|
// cannot be read. It is never reached through showView() — by then the
|
||||||
// state singleton refuses to be read; under an open popup,
|
// state singleton this file writes on every navigation refuses to be read
|
||||||
// src/popup/index.js also passes it to showView() so the screen it
|
// or saved — but it is listed so that every view-hiding loop covers it.
|
||||||
// replaces is left like any other.
|
|
||||||
"state-recovery",
|
"state-recovery",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -87,11 +86,6 @@ function hideError(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showView(name) {
|
function showView(name) {
|
||||||
// The recovery screen, once up, is never replaced: work still running
|
|
||||||
// when it went up, such as a transaction wait, must not take the user off
|
|
||||||
// it or clear what they exported or typed there
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
|
|
||||||
if (state.currentView === "state-recovery") return;
|
|
||||||
const leaving = state.currentView;
|
const leaving = state.currentView;
|
||||||
if (leaving && leaving !== name) {
|
if (leaving && leaving !== name) {
|
||||||
const onLeave = viewLeaveHandlers.get(leaving);
|
const onLeave = viewLeaveHandlers.get(leaving);
|
||||||
|
|||||||
@@ -34,11 +34,6 @@ function normalizeAddress(addr) {
|
|||||||
return (addr || "").toLowerCase();
|
return (addr || "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The characters that paint nothing; normalizeSymbol below says which they
|
|
||||||
// are. The signature screen marks them in a personal message
|
|
||||||
// (src/popup/views/approval.js).
|
|
||||||
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
|
|
||||||
|
|
||||||
// Fold a symbol onto what a user actually sees, and no further:
|
// Fold a symbol onto what a user actually sees, and no further:
|
||||||
//
|
//
|
||||||
// NFKC collapses compatibility variants that render as the ASCII
|
// NFKC collapses compatibility variants that render as the ASCII
|
||||||
@@ -87,7 +82,7 @@ const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
|
|||||||
function normalizeSymbol(symbol) {
|
function normalizeSymbol(symbol) {
|
||||||
return String(symbol || "")
|
return String(symbol || "")
|
||||||
.normalize("NFKC")
|
.normalize("NFKC")
|
||||||
.replace(INVISIBLE_CHARACTERS, "")
|
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
|
||||||
.trim()
|
.trim()
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -109,6 +104,5 @@ function isSpoofedSymbol(symbol, contractAddress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
INVISIBLE_CHARACTERS,
|
|
||||||
isSpoofedSymbol,
|
isSpoofedSymbol,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3232,64 +3232,6 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A right-to-left character must not move the characters around it: U+05C3
|
|
||||||
// between "5" and "00" would otherwise put "500" on screen before it. A
|
|
||||||
// paragraph separator (U+2029) before it, left in the text, would end the
|
|
||||||
// byte-order layout and bring that back
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/403).
|
|
||||||
test("a personal message is laid out in the order of its bytes (#403)", async (env) => {
|
|
||||||
const rightToLeft = String.fromCodePoint(0x05c3);
|
|
||||||
const text =
|
|
||||||
"Sign in" +
|
|
||||||
String.fromCodePoint(0x2029) +
|
|
||||||
"Pay 5" +
|
|
||||||
rightToLeft +
|
|
||||||
"00 ETH";
|
|
||||||
await startRequest(env.dapp, "sign-bidi", "personal_sign", [
|
|
||||||
hexlify(toUtf8Bytes(text)),
|
|
||||||
env.expectedAddress,
|
|
||||||
]);
|
|
||||||
const popup = await waitForApprovalWindow(env.ctx);
|
|
||||||
await visible(popup, "#view-approve-sign");
|
|
||||||
|
|
||||||
// The text on screen, marks included, and the left edge of each of its
|
|
||||||
// characters, in byte order. A character the browser's fonts draw with
|
|
||||||
// no width shares its neighbour's edge.
|
|
||||||
const shown = await popup.evaluate(() => {
|
|
||||||
const message = document.getElementById("approve-sign-message");
|
|
||||||
const walker = document.createTreeWalker(message, NodeFilter.SHOW_TEXT);
|
|
||||||
const range = document.createRange();
|
|
||||||
let text = "";
|
|
||||||
const lefts = [];
|
|
||||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
||||||
for (let i = 0; i < node.length; i++) {
|
|
||||||
range.setStart(node, i);
|
|
||||||
range.setEnd(node, i + 1);
|
|
||||||
lefts.push(range.getBoundingClientRect().left);
|
|
||||||
}
|
|
||||||
text += node.data;
|
|
||||||
}
|
|
||||||
return { text, lefts };
|
|
||||||
});
|
|
||||||
await clickAndClose(popup, "#btn-reject-sign");
|
|
||||||
await assertUserRejection(
|
|
||||||
env.dapp,
|
|
||||||
"sign-bidi",
|
|
||||||
"the byte-order personal_sign rejection",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert(
|
|
||||||
shown.text === "Sign inU+2029Pay 5" + rightToLeft + "00 ETH",
|
|
||||||
"the paragraph separator is not shown as a mark: " +
|
|
||||||
JSON.stringify(shown.text),
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
shown.lefts.every((left, i) => i === 0 || left >= shown.lefts[i - 1]),
|
|
||||||
"the personal message is not laid out in byte order: " +
|
|
||||||
JSON.stringify(shown.lefts),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => {
|
test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => {
|
||||||
await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [
|
await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [
|
||||||
env.expectedAddress,
|
env.expectedAddress,
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
// The signature prompt shows a personal message as the bytes that are signed
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/403): the raw data in hex, the
|
|
||||||
// text it decodes to with control characters, line and paragraph separators
|
|
||||||
// and characters that paint nothing marked rather than obeyed, markup shown as
|
|
||||||
// text, laid out in byte order, and a message that is not hex as plain text
|
|
||||||
// that cannot be signed.
|
|
||||||
//
|
|
||||||
// Driven against a minimal DOM stub in the shape
|
|
||||||
// tests/approvalOrigin.test.js uses. That the layout keeps right-to-left
|
|
||||||
// characters in byte order needs a real browser: tests/e2e/run.js checks it.
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { hexlify, toUtf8Bytes } = require("ethers");
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
|
|
||||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
|
||||||
|
|
||||||
// Built from their code points so that this file holds none of them.
|
|
||||||
const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
|
|
||||||
const POP_DIRECTIONAL_FORMATTING = String.fromCodePoint(0x202c);
|
|
||||||
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
|
|
||||||
const VARIATION_SELECTOR_1 = String.fromCodePoint(0xfe00);
|
|
||||||
const VARIATION_SELECTOR_17 = String.fromCodePoint(0xe0100);
|
|
||||||
const HANGUL_FILLER = String.fromCodePoint(0x3164);
|
|
||||||
const LINE_SEPARATOR = String.fromCodePoint(0x2028);
|
|
||||||
const PARAGRAPH_SEPARATOR = String.fromCodePoint(0x2029);
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
return {
|
|
||||||
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: () => [],
|
|
||||||
appendChild: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
const els = new Map();
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the signature prompt for a personal_sign of `message`, the way the
|
|
||||||
// popup does: it asks the background for the approval and show() draws it.
|
|
||||||
async function openPersonalSign(message) {
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" } };
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (!reply) return;
|
|
||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
|
||||||
reply({
|
|
||||||
type: "sign",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
signParams: { method: "personal_sign", message, from: FROM },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The message box's markup as the text a reader sees: tags dropped.
|
|
||||||
function shownMessage() {
|
|
||||||
return node("approve-sign-message").innerHTML.replace(/<[^>]*>/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.wallets = [];
|
|
||||||
state.activeAddress = FROM;
|
|
||||||
state.viewData = {};
|
|
||||||
state.viewStack = [];
|
|
||||||
state.currentView = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a right-to-left override is marked, so the text reads in byte order", async () => {
|
|
||||||
// Obeyed, the override shows "0001" as "1000".
|
|
||||||
const text =
|
|
||||||
"Pay " +
|
|
||||||
RIGHT_TO_LEFT_OVERRIDE +
|
|
||||||
"0001" +
|
|
||||||
POP_DIRECTIONAL_FORMATTING +
|
|
||||||
" ETH";
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes(text)));
|
|
||||||
const html = node("approve-sign-message").innerHTML;
|
|
||||||
expect(html).not.toContain(RIGHT_TO_LEFT_OVERRIDE);
|
|
||||||
expect(html).not.toContain(POP_DIRECTIONAL_FORMATTING);
|
|
||||||
expect(shownMessage()).toBe("Pay U+202E0001U+202C ETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a zero-width character is marked", async () => {
|
|
||||||
await openPersonalSign(
|
|
||||||
hexlify(toUtf8Bytes("pay" + ZERO_WIDTH_SPACE + "pal.com")),
|
|
||||||
);
|
|
||||||
expect(node("approve-sign-message").innerHTML).not.toContain(
|
|
||||||
ZERO_WIDTH_SPACE,
|
|
||||||
);
|
|
||||||
expect(shownMessage()).toBe("payU+200Bpal.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("variation selectors and a Hangul filler are marked", async () => {
|
|
||||||
// Each paints nothing, so a page could hide bytes after "Sign in".
|
|
||||||
await openPersonalSign(
|
|
||||||
hexlify(
|
|
||||||
toUtf8Bytes(
|
|
||||||
"Sign in" +
|
|
||||||
VARIATION_SELECTOR_1 +
|
|
||||||
VARIATION_SELECTOR_17 +
|
|
||||||
HANGUL_FILLER,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(shownMessage()).toBe("Sign inU+FE00U+E0100U+3164");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the message is laid out in byte order", async () => {
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes("Hello")));
|
|
||||||
expect(
|
|
||||||
node("approve-sign-message").classList.contains("am-byte-order"),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a control character other than a line feed is marked", async () => {
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes("a\u0000b\tc")));
|
|
||||||
expect(shownMessage()).toBe("aU+0000bU+0009c");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("line and paragraph separators are marked", async () => {
|
|
||||||
// Left in the text, a paragraph separator would end the byte-order
|
|
||||||
// layout for everything after it.
|
|
||||||
await openPersonalSign(
|
|
||||||
hexlify(toUtf8Bytes("a" + LINE_SEPARATOR + "b" + PARAGRAPH_SEPARATOR)),
|
|
||||||
);
|
|
||||||
const html = node("approve-sign-message").innerHTML;
|
|
||||||
expect(html).not.toContain(LINE_SEPARATOR);
|
|
||||||
expect(html).not.toContain(PARAGRAPH_SEPARATOR);
|
|
||||||
expect(shownMessage()).toBe("aU+2028bU+2029");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a line feed is shown as a line break", async () => {
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes("Sign in\nNonce: 7")));
|
|
||||||
expect(node("approve-sign-message").innerHTML).toBe("Sign in<br>Nonce: 7");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The message box is written as HTML, so a site's markup has to arrive there
|
|
||||||
// escaped, as the text it is.
|
|
||||||
const MARKUP = "<b>x</b><img src=x onerror=alert(1)>";
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["a hex message", hexlify(toUtf8Bytes(MARKUP))],
|
|
||||||
["a message that is not hex", MARKUP],
|
|
||||||
])("markup in %s is shown as text, not as markup", async (_, message) => {
|
|
||||||
await openPersonalSign(message);
|
|
||||||
expect(node("approve-sign-message").innerHTML).toBe(
|
|
||||||
"<b>x</b><img src=x onerror=alert(1)>",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the raw hex is shown alongside the text", async () => {
|
|
||||||
await openPersonalSign("0x48656c6c6f");
|
|
||||||
expect(shownMessage()).toBe("Hello");
|
|
||||||
expect(node("approve-sign-hex").textContent).toBe("0x48656c6c6f");
|
|
||||||
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hex with an uppercase 0X is read as hex, as signing reads it", async () => {
|
|
||||||
await openPersonalSign("0X48656C6C6F");
|
|
||||||
expect(shownMessage()).toBe("Hello");
|
|
||||||
expect(node("approve-sign-hex").textContent).toBe("0X48656C6C6F");
|
|
||||||
expect(node("btn-approve-sign").disabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("bytes that are not text are shown only as hex", async () => {
|
|
||||||
await openPersonalSign("0xff00");
|
|
||||||
expect(node("approve-sign-message").textContent).toBe(
|
|
||||||
"This message is not text.",
|
|
||||||
);
|
|
||||||
expect(node("approve-sign-hex").textContent).toBe("0xff00");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a message that is not hex is shown as text and cannot be signed", async () => {
|
|
||||||
await openPersonalSign("Hello world");
|
|
||||||
expect(shownMessage()).toBe("Hello world");
|
|
||||||
expect(node("approve-sign-error").textContent).toBe(
|
|
||||||
"This message is plain text, not hex, so it cannot be signed.",
|
|
||||||
);
|
|
||||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
|
||||||
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
+6
-101
@@ -157,7 +157,7 @@ describe("a popup already open when the stored profile becomes unreadable", () =
|
|||||||
expect(env.visibleViews()).toEqual(["main"]);
|
expect(env.visibleViews()).toEqual(["main"]);
|
||||||
|
|
||||||
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
||||||
await env.tick();
|
await env.refresh();
|
||||||
|
|
||||||
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||||
expect(env.text("state-recovery-problem").length).toBeGreaterThan(10);
|
expect(env.text("state-recovery-problem").length).toBeGreaterThan(10);
|
||||||
@@ -165,131 +165,36 @@ describe("a popup already open when the stored profile becomes unreadable", () =
|
|||||||
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
|
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stops the ten-second refresh", async () => {
|
|
||||||
const env = await bootPopup(unversionedValidProfile());
|
|
||||||
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
|
||||||
await env.tick();
|
|
||||||
const { refreshBalances } = require("../src/shared/balances");
|
|
||||||
const calls = refreshBalances.mock.calls.length;
|
|
||||||
|
|
||||||
await env.tick();
|
|
||||||
|
|
||||||
expect(refreshBalances).toHaveBeenCalledTimes(calls);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a later save does not clear what the user exported or typed", async () => {
|
test("a later save does not clear what the user exported or typed", async () => {
|
||||||
const env = await bootPopup(unversionedValidProfile());
|
const env = await bootPopup(unversionedValidProfile());
|
||||||
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
|
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
|
||||||
await env.tick();
|
await env.refresh();
|
||||||
|
|
||||||
await env.click("btn-state-recovery-export");
|
await env.click("btn-state-recovery-export");
|
||||||
env.node("state-recovery-reset-input").value = "erase my";
|
env.node("state-recovery-reset-input").value = "erase my";
|
||||||
// Such as the save of a refresh already in flight when the screen
|
// A refresh that was already in flight when the screen went up.
|
||||||
// went up.
|
await env.refresh();
|
||||||
const { saveState } = require("../src/shared/state");
|
|
||||||
await expect(saveState()).rejects.toThrow();
|
|
||||||
await env.settle();
|
|
||||||
|
|
||||||
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||||
expect(env.hidden("state-recovery-blob")).toBe(false);
|
expect(env.hidden("state-recovery-blob")).toBe(false);
|
||||||
expect(env.value("state-recovery-reset-input")).toBe("erase my");
|
expect(env.value("state-recovery-reset-input")).toBe("erase my");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a transaction wait that ends under it does not replace it", async () => {
|
|
||||||
const env = await bootPopup(
|
|
||||||
unversionedValidProfile({
|
|
||||||
currentView: "wait-tx",
|
|
||||||
viewData: {
|
|
||||||
pendingWait: {
|
|
||||||
hash: "0x1",
|
|
||||||
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
|
|
||||||
broadcastTime: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(env.visibleViews()).toEqual(["wait-tx"]);
|
|
||||||
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
|
|
||||||
await env.tick();
|
|
||||||
await env.click("btn-state-recovery-export");
|
|
||||||
env.node("state-recovery-reset-input").value = "erase my";
|
|
||||||
|
|
||||||
// The test provider answers no receipt lookup, and six that fail in
|
|
||||||
// a row end the wait with an error.
|
|
||||||
for (let i = 0; i < 6; i++) await env.tick();
|
|
||||||
|
|
||||||
expect(env.text("error-tx-message")).toMatch(/could not be reached/);
|
|
||||||
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
|
||||||
expect(env.hidden("state-recovery-blob")).toBe(false);
|
|
||||||
expect(env.value("state-recovery-reset-input")).toBe("erase my");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a storage read that fails once leaves the wallet list up", async () => {
|
test("a storage read that fails once leaves the wallet list up", async () => {
|
||||||
const env = await bootPopup(unversionedValidProfile());
|
const env = await bootPopup(unversionedValidProfile());
|
||||||
|
|
||||||
env.storage.local.get.mockRejectedValueOnce(
|
env.storage.local.get.mockRejectedValueOnce(
|
||||||
new Error("IO error: storage busy"),
|
new Error("IO error: storage busy"),
|
||||||
);
|
);
|
||||||
await env.tick();
|
await env.refresh();
|
||||||
|
|
||||||
// Reported as a failed save, not mistaken for an unreadable profile.
|
// Reported as a failed save, not mistaken for an unreadable profile.
|
||||||
expect(env.visibleViews()).toEqual(["main"]);
|
expect(env.visibleViews()).toEqual(["main"]);
|
||||||
expect(env.node("save-failure-banner")).not.toBeNull();
|
expect(env.node("save-failure-banner")).not.toBeNull();
|
||||||
|
|
||||||
await env.tick();
|
await env.refresh();
|
||||||
expect(env.visibleViews()).toEqual(["main"]);
|
expect(env.visibleViews()).toEqual(["main"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The screen it replaces is left as any navigation leaves it: the rules
|
|
||||||
// at the top of src/popup/views/showPhrase.js and exportPrivkey.js hold
|
|
||||||
// for this way off them too.
|
|
||||||
describe("from a screen holding a secret", () => {
|
|
||||||
const PHRASE =
|
|
||||||
"abandon abandon abandon abandon abandon abandon abandon" +
|
|
||||||
" abandon abandon abandon abandon about";
|
|
||||||
|
|
||||||
afterEach(() => jest.dontMock("../src/shared/vault"));
|
|
||||||
|
|
||||||
test("a recovery phrase on screen is wiped", async () => {
|
|
||||||
jest.doMock("../src/shared/vault", () => ({
|
|
||||||
decryptWithPassword: async () => PHRASE,
|
|
||||||
}));
|
|
||||||
const env = await bootPopup(unversionedValidProfile());
|
|
||||||
require("../src/popup/views/showPhrase").show(0);
|
|
||||||
env.node("show-phrase-password").value = "password";
|
|
||||||
await env.click("btn-show-phrase-reveal");
|
|
||||||
expect(env.text("show-phrase-value")).toBe(PHRASE);
|
|
||||||
|
|
||||||
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
|
||||||
await env.tick();
|
|
||||||
|
|
||||||
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
|
||||||
expect(env.text("show-phrase-value")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a private key still being decrypted is never written", async () => {
|
|
||||||
let answer;
|
|
||||||
jest.doMock("../src/shared/vault", () => ({
|
|
||||||
decryptWithPassword: () =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
answer = resolve;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const env = await bootPopup(unversionedValidProfile());
|
|
||||||
require("../src/popup/views/exportPrivkey").show(0, 0);
|
|
||||||
env.node("export-privkey-password").value = "password";
|
|
||||||
const revealing = env.click("btn-export-privkey-confirm");
|
|
||||||
|
|
||||||
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
|
||||||
await env.tick();
|
|
||||||
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
|
||||||
expect(env.value("export-privkey-password")).toBe("");
|
|
||||||
|
|
||||||
answer(PHRASE);
|
|
||||||
await revealing;
|
|
||||||
expect(env.text("export-privkey-value")).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("an unversioned profile that is perfectly valid", () => {
|
describe("an unversioned profile that is perfectly valid", () => {
|
||||||
|
|||||||
+11
-22
@@ -40,10 +40,6 @@ jest.doMock("libsodium-wrappers-sumo", () => sodium);
|
|||||||
jest.doMock("qrcode", () => QRCode);
|
jest.doMock("qrcode", () => QRCode);
|
||||||
jest.doMock("ethereum-blockies-base64", () => makeBlockie);
|
jest.doMock("ethereum-blockies-base64", () => makeBlockie);
|
||||||
|
|
||||||
// Taken before any boot replaces them; see bootPopup().
|
|
||||||
const realSetInterval = globalThis.setInterval;
|
|
||||||
const realClearInterval = globalThis.clearInterval;
|
|
||||||
|
|
||||||
const POPUP_HTML = fs.readFileSync(
|
const POPUP_HTML = fs.readFileSync(
|
||||||
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
|
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -296,19 +292,13 @@ async function bootPopup(stored, options) {
|
|||||||
}),
|
}),
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
};
|
};
|
||||||
// The ten-second refresh init() starts, and a transaction wait's timers,
|
// The 10s refresh loop init() starts would outlive the test, so it is
|
||||||
// would outlive the test. So every interval is recorded rather than
|
// recorded rather than started, and refresh() below runs it once.
|
||||||
// started, clearInterval() removes it as a browser would, and tick() below
|
const realSetInterval = globalThis.setInterval;
|
||||||
// runs the ones still set. Put back by cleanupPopup().
|
const intervals = [];
|
||||||
const intervals = new Map();
|
|
||||||
let lastId = 0;
|
|
||||||
globalThis.setInterval = (fn) => {
|
globalThis.setInterval = (fn) => {
|
||||||
lastId += 1;
|
intervals.push(fn);
|
||||||
intervals.set(lastId, fn);
|
return 0;
|
||||||
return lastId;
|
|
||||||
};
|
|
||||||
globalThis.clearInterval = (id) => {
|
|
||||||
intervals.delete(id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
require("../../src/popup/index");
|
require("../../src/popup/index");
|
||||||
@@ -331,6 +321,8 @@ async function bootPopup(stored, options) {
|
|||||||
}
|
}
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
|
globalThis.setInterval = realSetInterval;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storage,
|
storage,
|
||||||
document,
|
document,
|
||||||
@@ -350,10 +342,9 @@ async function bootPopup(stored, options) {
|
|||||||
for (const fn of fns) await fn();
|
for (const fn of fns) await fn();
|
||||||
await settle();
|
await settle();
|
||||||
},
|
},
|
||||||
// Every interval still set runs once: the ten-second refresh, and a
|
// One tick of the refresh loop, as if ten seconds had passed.
|
||||||
// transaction wait's receipt poll and elapsed counter while one runs.
|
refresh: async () => {
|
||||||
tick: async () => {
|
for (const fn of intervals) await fn();
|
||||||
for (const fn of intervals.values()) await fn();
|
|
||||||
await settle();
|
await settle();
|
||||||
},
|
},
|
||||||
settle,
|
settle,
|
||||||
@@ -373,8 +364,6 @@ function cleanupPopup() {
|
|||||||
delete globalThis.chrome;
|
delete globalThis.chrome;
|
||||||
delete globalThis.document;
|
delete globalThis.document;
|
||||||
delete globalThis.window;
|
delete globalThis.window;
|
||||||
globalThis.setInterval = realSetInterval;
|
|
||||||
globalThis.clearInterval = realClearInterval;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
Reference in New Issue
Block a user