Compare commits
3 Commits
288c5560de
...
ee9932b01b
| Author | SHA1 | Date | |
|---|---|---|---|
| ee9932b01b | |||
| afe6ddaea0 | |||
| 23712b53cb |
86
README.md
86
README.md
@@ -210,29 +210,43 @@ deterministic. Navigation uses **classic** WebDriver `POST /session/{id}/url`,
|
|||||||
because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright.
|
because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright.
|
||||||
|
|
||||||
**Any uncaught error from a `moz-extension://` source fails the run**, including
|
**Any uncaught error from a `moz-extension://` source fails the run**, including
|
||||||
errors from the background page and content scripts, which the suite never
|
errors from the background page, which the suite never navigates to: a `throw`
|
||||||
navigates to. Errors are read from the privileged `nsIConsoleService` in
|
at the top of `src/background/index.js` kills the background page and fails
|
||||||
Marionette's chrome context and filtered to non-warning entries whose
|
step 1. Content-script errors should arrive by the same route, but this suite
|
||||||
`sourceName` is the extension origin. That mechanism is not a stylistic choice.
|
does not exercise it and does not claim it — with `--network none` there is no
|
||||||
WebDriver BiDi's `log.entryAdded` delivers **nothing** for extension pages: on a
|
`http://` page for a content script to be injected into. Errors from add-on
|
||||||
plain `http://` page it reports uncaught errors with stack traces, and on the
|
install and background startup are folded into step 1 rather than discarded.
|
||||||
`moz-extension://` popup it reports zero events, because Firefox's remote agent
|
Errors are read from the privileged `nsIConsoleService` in Marionette's chrome
|
||||||
excludes extension browsing contexts from BiDi observation. Any harness built on
|
context and filtered to non-warning entries whose `sourceName` is the extension
|
||||||
Playwright-BiDi or Puppeteer-BiDi would therefore see nothing and report
|
origin. That mechanism is not a stylistic choice. WebDriver BiDi's
|
||||||
success, which is exactly the vacuous check this repo has already shipped twice.
|
`log.entryAdded` delivers **nothing** for extension pages: on a plain `http://`
|
||||||
Do not migrate this suite to BiDi.
|
page it reports uncaught errors with stack traces, and on the `moz-extension://`
|
||||||
|
popup it reports zero events, because Firefox's remote agent excludes extension
|
||||||
|
browsing contexts from BiDi observation. Any harness built on Playwright-BiDi or
|
||||||
|
Puppeteer-BiDi would therefore see nothing and report success, which is exactly
|
||||||
|
the vacuous check this repo has already shipped twice. Do not migrate this suite
|
||||||
|
to BiDi.
|
||||||
|
|
||||||
Two limits are worth knowing, both real differences from the Chrome suite:
|
Two limits are worth knowing, both real differences from the Chrome suite:
|
||||||
|
|
||||||
- **Error capture is poll-based, not event-streamed.** The console is drained at
|
- **Error capture is poll-based, not event-streamed.** The console is drained at
|
||||||
each step boundary, so an error is attributed to the step it was drained
|
each step boundary, so an error is attributed to the step it was drained
|
||||||
after. Nothing is lost, but an error cannot be located within a step the way
|
after, not to a moment within it. The window that is drained runs from add-on
|
||||||
the Chrome suite's `pageerror` events can.
|
install to one second after the last step returns; an error logged later than
|
||||||
- **Nothing is stubbed.** There is no fixture layer; the container runs with
|
that ~1s tail is missed entirely, because the browser is torn down first.
|
||||||
`--network none` instead, so the run is offline and deterministic and no
|
Within that window nothing is dropped, but an error cannot be located within a
|
||||||
request can escape. The extension swallows its own fetch failures, so the
|
step the way the Chrome suite's `pageerror` events can.
|
||||||
flows are unaffected. This is weaker than the Chrome suite's interception: it
|
- **Nothing is stubbed, which inverts the coverage of network-dependent code.**
|
||||||
|
There is no fixture layer; the container runs with `--network none` instead,
|
||||||
|
so the run is offline and deterministic and no request can escape. The
|
||||||
|
extension swallows its own fetch failures, so the flows are unaffected — but
|
||||||
|
every network call fails, so only the _failure_ branches of code that depends
|
||||||
|
on one are ever executed. A `ReferenceError` in the success path of
|
||||||
|
`renderTransactions`, or of price or balance rendering, passes this suite
|
||||||
|
green. The offline run is also weaker than the Chrome suite's interception: it
|
||||||
proves nothing got out, but it cannot report which requests were attempted.
|
proves nothing got out, but it cannot report which requests were attempted.
|
||||||
|
Closing that gap needs a fixture layer, deliberately out of scope for this
|
||||||
|
harness.
|
||||||
|
|
||||||
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
|
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
|
||||||
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
|
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
|
||||||
@@ -553,6 +567,14 @@ ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
|
|||||||
so the popup can never reopen onto one of them with no password prompt in front
|
so the popup can never reopen onto one of them with no password prompt in front
|
||||||
of it.
|
of it.
|
||||||
|
|
||||||
|
Every screen that holds secret material in the page registers a cleanup with
|
||||||
|
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
|
||||||
|
exit from that screen rather than only on its "Back" button, so nothing secret
|
||||||
|
survives in a hidden view once the user has navigated away by any route. That
|
||||||
|
covers the revealed private key and recovery phrase, the recovery phrase,
|
||||||
|
private key or extended private key entered on AddWallet, and the password typed
|
||||||
|
on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
||||||
|
|
||||||
#### Welcome (`welcome`)
|
#### Welcome (`welcome`)
|
||||||
|
|
||||||
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
|
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
|
||||||
@@ -663,10 +685,15 @@ of it.
|
|||||||
- "Reveal" (correct password) → decrypts the wallet secret, derives this
|
- "Reveal" (correct password) → decrypts the wallet secret, derives this
|
||||||
address's key, hides the password input and shows the key (no screen
|
address's key, hides the password input and shows the key (no screen
|
||||||
change)
|
change)
|
||||||
- "Reveal" (wrong password) → "Wrong password." on the error line, nothing
|
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
|
||||||
revealed
|
revealed (no screen change)
|
||||||
- "Back" → clears the key and password from the DOM, then → previous screen
|
- "Back" → previous screen (AddressDetail)
|
||||||
(AddressDetail)
|
- **Secret handling**: nothing is decrypted, no key is derived, and nothing is
|
||||||
|
written into the page until the password is accepted; the key is never stored
|
||||||
|
in state, and it is wiped from the page whenever the screen is left by any
|
||||||
|
route, including the Settings gear. A decrypt still running when the screen is
|
||||||
|
left is discarded rather than written. The screen is not restorable, so
|
||||||
|
reopening the popup lands on Home rather than back on the key.
|
||||||
|
|
||||||
#### AddressToken (`address-token`)
|
#### AddressToken (`address-token`)
|
||||||
|
|
||||||
@@ -755,10 +782,23 @@ of it.
|
|||||||
- To: color dot + full address + etherscan link
|
- To: color dot + full address + etherscan link
|
||||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||||
- Count-up timer: "Waiting for confirmation... Ns"
|
- Count-up timer: "Waiting for confirmation... Ns"
|
||||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds.
|
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
||||||
|
persisted: closing and reopening the popup resumes the poll, with the elapsed
|
||||||
|
counter and the timeout deadline still measured from the original broadcast. A
|
||||||
|
lookup that fails is retried on the next tick rather than counted as a missing
|
||||||
|
receipt, because a failed lookup says nothing about the transaction; but six
|
||||||
|
failures in a row (60 seconds at the poll cadence) end the wait, so an RPC
|
||||||
|
that never answers cannot leave it running indefinitely. Any lookup that
|
||||||
|
answers resets that count.
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Receipt found → **SuccessTx**
|
- Receipt found → **SuccessTx**
|
||||||
- 60 seconds without confirmation → **ErrorTx** (timeout message)
|
- A lookup that answers "no receipt" 60 seconds or more after broadcast →
|
||||||
|
**ErrorTx** (timeout message)
|
||||||
|
- Six consecutive failed lookups → **ErrorTx**, with a message naming the
|
||||||
|
unreachable network and pointing at the RPC URL in Settings. This is a
|
||||||
|
different fact from the timeout — the chain was never asked — and says so
|
||||||
|
- Exactly one outcome: a receipt found on the tick that crosses the deadline
|
||||||
|
wins, and no outcome can be rendered over another
|
||||||
|
|
||||||
#### SuccessTx (`success-tx`)
|
#### SuccessTx (`success-tx`)
|
||||||
|
|
||||||
|
|||||||
21
TODO.md
21
TODO.md
@@ -51,9 +51,24 @@ undefined identifiers, which is how
|
|||||||
client over `fetch` against geckodriver — with `node`, Firefox 153.0.3 and
|
client over `fetch` against geckodriver — with `node`, Firefox 153.0.3 and
|
||||||
geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the
|
geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the
|
||||||
privileged console service in Marionette's chrome context, because BiDi
|
privileged console service in Marionette's chrome context, because BiDi
|
||||||
`log.entryAdded` reports nothing at all for extension pages; demonstrated
|
`log.entryAdded` reports nothing at all for extension pages, and errors logged
|
||||||
discriminating by exiting 1 on a build with one import removed and 0 with it
|
during add-on install and background startup are folded into step 1 instead of
|
||||||
restored ([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)).
|
being cleared; demonstrated discriminating by exiting 1 on a `throw` at the
|
||||||
|
top of `src/background/index.js` and on a build with one import removed, and 0
|
||||||
|
on the branch as it stands
|
||||||
|
([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)).
|
||||||
|
- 2026-08-12: WaitTx lifecycle: a receipt and the 60-second timeout can no
|
||||||
|
longer both render on one tick, no timer or in-flight lookup outlives its
|
||||||
|
wait, a failed receipt lookup no longer counts as a timeout (but six in a row
|
||||||
|
end the wait, reported as an unreachable network rather than as a timeout),
|
||||||
|
and the wait now resumes after a popup close
|
||||||
|
([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)).
|
||||||
|
- 2026-08-12: The private key export screen now wipes the key from the page
|
||||||
|
whenever it is left by any route, and a decrypt still in flight when the
|
||||||
|
screen is left is discarded instead of written; the same `onViewLeave()`
|
||||||
|
cleanup was extended to every other screen holding secret material in the DOM
|
||||||
|
(AddWallet, ConfirmTx, DeleteWallet, ApproveTx, ApproveSign)
|
||||||
|
([#221](https://git.eeqj.de/sneak/AutistMask/issues/221)).
|
||||||
- 2026-08-12: An xprv wallet already in storage that was imported from a
|
- 2026-08-12: An xprv wallet already in storage that was imported from a
|
||||||
non-master key is detected from the depth of its stored `xpub`, explained in
|
non-master key is detected from the depth of its stored `xpub`, explained in
|
||||||
the wallet list, and blocked from signing, sending and private-key export
|
the wallet list, and blocked from signing, sending and private-key export
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ function restoreView() {
|
|||||||
fallbackView();
|
fallbackView();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "wait-tx":
|
||||||
|
// Resumes the receipt poll from the persisted broadcast time.
|
||||||
|
if (!txStatus.restoreWait()) {
|
||||||
|
fallbackView();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "success-tx":
|
case "success-tx":
|
||||||
if (state.viewData && state.viewData.hash) {
|
if (state.viewData && state.viewData.hash) {
|
||||||
txStatus.renderSuccess();
|
txStatus.renderSuccess();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const RESTORABLE_VIEWS = new Set([
|
|||||||
"settings-addtoken",
|
"settings-addtoken",
|
||||||
"confirm-tx",
|
"confirm-tx",
|
||||||
"transaction",
|
"transaction",
|
||||||
|
"wait-tx",
|
||||||
"success-tx",
|
"success-tx",
|
||||||
"error-tx",
|
"error-tx",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
|
const {
|
||||||
|
$,
|
||||||
|
showView,
|
||||||
|
showFlash,
|
||||||
|
goBack,
|
||||||
|
clearViewStack,
|
||||||
|
onViewLeave,
|
||||||
|
} = require("./helpers");
|
||||||
const {
|
const {
|
||||||
generateMnemonic,
|
generateMnemonic,
|
||||||
hdWalletFromMnemonic,
|
hdWalletFromMnemonic,
|
||||||
@@ -66,13 +73,23 @@ function switchMode(mode) {
|
|||||||
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
|
$("add-wallet-password-hint").textContent = PASSWORD_HINTS[mode];
|
||||||
}
|
}
|
||||||
|
|
||||||
function show() {
|
// Wipe the secret material this screen holds in the DOM: a generated or
|
||||||
|
// pasted recovery phrase, an imported private key or extended private key,
|
||||||
|
// and the password that would encrypt them. Registered as the view-leave
|
||||||
|
// handler as well as run on entry, so none of it survives in the hidden
|
||||||
|
// view after the user navigates away by any route, including the Settings
|
||||||
|
// gear and the import itself.
|
||||||
|
function clear() {
|
||||||
$("wallet-mnemonic").value = "";
|
$("wallet-mnemonic").value = "";
|
||||||
$("import-private-key").value = "";
|
$("import-private-key").value = "";
|
||||||
$("import-xprv-key").value = "";
|
$("import-xprv-key").value = "";
|
||||||
$("add-wallet-password").value = "";
|
$("add-wallet-password").value = "";
|
||||||
$("add-wallet-password-confirm").value = "";
|
$("add-wallet-password-confirm").value = "";
|
||||||
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
clear();
|
||||||
switchMode("mnemonic");
|
switchMode("mnemonic");
|
||||||
showView("add-wallet");
|
showView("add-wallet");
|
||||||
}
|
}
|
||||||
@@ -288,6 +305,8 @@ async function importXprvKey(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
|
onViewLeave("add-wallet", clear);
|
||||||
|
|
||||||
// Tab click handlers
|
// Tab click handlers
|
||||||
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
|
$("tab-mnemonic").addEventListener("click", () => switchMode("mnemonic"));
|
||||||
$("tab-privkey").addEventListener("click", () => switchMode("privkey"));
|
$("tab-privkey").addEventListener("click", () => switchMode("privkey"));
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
flashCopyFeedback,
|
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
@@ -27,8 +26,7 @@ const {
|
|||||||
} = require("./send");
|
} = require("./send");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
const makeBlockie = require("ethereum-blockies-base64");
|
const makeBlockie = require("ethereum-blockies-base64");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const exportPrivkey = require("./exportPrivkey");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
|
||||||
const { walletDefect } = require("../../shared/walletDefects");
|
const { walletDefect } = require("../../shared/walletDefects");
|
||||||
|
|
||||||
// The defect of the wallet the selected address belongs to, or null. Both the
|
// The defect of the wallet the selected address belongs to, or null. Both the
|
||||||
@@ -321,81 +319,12 @@ function init(_ctx) {
|
|||||||
showFlash(defect.shortMessage);
|
showFlash(defect.shortMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pushCurrentView();
|
// No pushCurrentView() here: exportPrivkey.show() can return
|
||||||
const wallet = state.wallets[state.selectedWallet];
|
// without navigating, so it does its own push.
|
||||||
const addr = wallet.addresses[state.selectedAddress];
|
exportPrivkey.show(state.selectedWallet, state.selectedAddress);
|
||||||
const blockieEl = $("export-privkey-jazzicon");
|
|
||||||
blockieEl.innerHTML = "";
|
|
||||||
const bImg = document.createElement("img");
|
|
||||||
bImg.src = makeBlockie(addr.address);
|
|
||||||
bImg.width = 48;
|
|
||||||
bImg.height = 48;
|
|
||||||
bImg.style.imageRendering = "pixelated";
|
|
||||||
bImg.style.borderRadius = "50%";
|
|
||||||
blockieEl.appendChild(bImg);
|
|
||||||
$("export-privkey-title").textContent =
|
|
||||||
wallet.name + " \u2014 Address " + (state.selectedAddress + 1);
|
|
||||||
const exportAddrContainer = $("export-privkey-dot").parentElement;
|
|
||||||
exportAddrContainer.innerHTML = renderAddressHtml(addr.address);
|
|
||||||
attachCopyHandlers(exportAddrContainer);
|
|
||||||
$("export-privkey-password").value = "";
|
|
||||||
$("export-privkey-flash").textContent = "";
|
|
||||||
$("export-privkey-flash").style.visibility = "hidden";
|
|
||||||
$("export-privkey-password-section").classList.remove("hidden");
|
|
||||||
$("export-privkey-result").classList.add("hidden");
|
|
||||||
$("export-privkey-value").textContent = "";
|
|
||||||
showView("export-privkey");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-export-privkey-confirm").addEventListener("click", async () => {
|
exportPrivkey.init();
|
||||||
const password = $("export-privkey-password").value;
|
|
||||||
if (!password) {
|
|
||||||
$("export-privkey-flash").textContent = "Password is required.";
|
|
||||||
$("export-privkey-flash").style.visibility = "visible";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = $("btn-export-privkey-confirm");
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.classList.add("text-muted");
|
|
||||||
const wallet = state.wallets[state.selectedWallet];
|
|
||||||
try {
|
|
||||||
const secret = await decryptWithPassword(
|
|
||||||
wallet.encryptedSecret,
|
|
||||||
password,
|
|
||||||
);
|
|
||||||
const signer = getSignerForAddress(
|
|
||||||
wallet,
|
|
||||||
state.selectedAddress,
|
|
||||||
secret,
|
|
||||||
);
|
|
||||||
const privateKey = signer.privateKey;
|
|
||||||
$("export-privkey-password-section").classList.add("hidden");
|
|
||||||
$("export-privkey-value").textContent = privateKey;
|
|
||||||
$("export-privkey-result").classList.remove("hidden");
|
|
||||||
$("export-privkey-flash").style.visibility = "hidden";
|
|
||||||
} catch {
|
|
||||||
$("export-privkey-flash").textContent = "Wrong password.";
|
|
||||||
$("export-privkey-flash").style.visibility = "visible";
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.classList.remove("text-muted");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$("export-privkey-value").addEventListener("click", () => {
|
|
||||||
const key = $("export-privkey-value").textContent;
|
|
||||||
if (key) {
|
|
||||||
navigator.clipboard.writeText(key);
|
|
||||||
showFlash("Copied!");
|
|
||||||
flashCopyFeedback($("export-privkey-value"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$("btn-export-privkey-back").addEventListener("click", () => {
|
|
||||||
$("export-privkey-value").textContent = "";
|
|
||||||
$("export-privkey-password").value = "";
|
|
||||||
goBack();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show };
|
module.exports = { init, show };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
hideError,
|
hideError,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
|
onViewLeave,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const {
|
const {
|
||||||
@@ -461,7 +462,24 @@ function findActiveWallet() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop the password from the DOM when either approval screen is left. The
|
||||||
|
// approval window navigates on after a signature — approve-tx goes to the
|
||||||
|
// wait screen — and the password must not sit in the hidden view for the
|
||||||
|
// life of that window.
|
||||||
|
function clearTxPassword() {
|
||||||
|
$("approve-tx-password").value = "";
|
||||||
|
hideError("approve-tx-error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSignPassword() {
|
||||||
|
$("approve-sign-password").value = "";
|
||||||
|
hideError("approve-sign-error");
|
||||||
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
|
onViewLeave("approve-tx", clearTxPassword);
|
||||||
|
onViewLeave("approve-sign", clearSignPassword);
|
||||||
|
|
||||||
$("approve-remember").addEventListener("change", async () => {
|
$("approve-remember").addEventListener("change", async () => {
|
||||||
state.rememberSiteChoice = $("approve-remember").checked;
|
state.rememberSiteChoice = $("approve-remember").checked;
|
||||||
await saveState();
|
await saveState();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const {
|
|||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
|
onViewLeave,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentNetwork } = require("../../shared/state");
|
const { state, currentNetwork } = require("../../shared/state");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
@@ -390,7 +391,17 @@ async function checkRecipientHistory(txInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop the password from the DOM. Registered as the view-leave handler so
|
||||||
|
// it does not sit in the hidden view once the screen navigates on — to the
|
||||||
|
// wait screen after a send, or anywhere else the user goes.
|
||||||
|
function clearPassword() {
|
||||||
|
$("confirm-tx-password").value = "";
|
||||||
|
hideError("confirm-tx-password-error");
|
||||||
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
|
onViewLeave("confirm-tx", clearPassword);
|
||||||
|
|
||||||
$("btn-confirm-send").addEventListener("click", async () => {
|
$("btn-confirm-send").addEventListener("click", async () => {
|
||||||
const password = $("confirm-tx-password").value;
|
const password = $("confirm-tx-password").value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
const { $, showView, showFlash, goBack, clearViewStack } = require("./helpers");
|
const {
|
||||||
|
$,
|
||||||
|
showView,
|
||||||
|
showFlash,
|
||||||
|
goBack,
|
||||||
|
clearViewStack,
|
||||||
|
onViewLeave,
|
||||||
|
} = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
const {
|
const {
|
||||||
@@ -9,22 +16,34 @@ const {
|
|||||||
let deleteWalletIndex = null;
|
let deleteWalletIndex = null;
|
||||||
let ctx = null;
|
let ctx = null;
|
||||||
|
|
||||||
|
// Drop the password from the DOM and the wallet selection from the
|
||||||
|
// closure. Registered as the view-leave handler as well as run on entry,
|
||||||
|
// so the typed password does not sit in the hidden view after the user
|
||||||
|
// navigates away by any route, including the Settings gear.
|
||||||
|
function clear() {
|
||||||
|
deleteWalletIndex = null;
|
||||||
|
$("delete-wallet-password").value = "";
|
||||||
|
$("delete-wallet-flash").textContent = "";
|
||||||
|
$("delete-wallet-flash").style.visibility = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
function show(walletIdx) {
|
function show(walletIdx) {
|
||||||
|
clear();
|
||||||
deleteWalletIndex = walletIdx;
|
deleteWalletIndex = walletIdx;
|
||||||
const wallet = state.wallets[walletIdx];
|
const wallet = state.wallets[walletIdx];
|
||||||
$("delete-wallet-name").textContent =
|
$("delete-wallet-name").textContent =
|
||||||
wallet.name || "Wallet " + (walletIdx + 1);
|
wallet.name || "Wallet " + (walletIdx + 1);
|
||||||
$("delete-wallet-password").value = "";
|
|
||||||
$("delete-wallet-flash").textContent = "";
|
|
||||||
$("delete-wallet-flash").style.visibility = "hidden";
|
|
||||||
showView("delete-wallet-confirm");
|
showView("delete-wallet-confirm");
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(_ctx) {
|
function init(_ctx) {
|
||||||
ctx = _ctx;
|
ctx = _ctx;
|
||||||
|
|
||||||
|
onViewLeave("delete-wallet-confirm", clear);
|
||||||
|
|
||||||
|
// No wipe here: goBack() routes through showView(), which runs the
|
||||||
|
// leave hook.
|
||||||
$("btn-delete-wallet-back").addEventListener("click", () => {
|
$("btn-delete-wallet-back").addEventListener("click", () => {
|
||||||
deleteWalletIndex = null;
|
|
||||||
goBack();
|
goBack();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
174
src/popup/views/exportPrivkey.js
Normal file
174
src/popup/views/exportPrivkey.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
// Private key export for a single address.
|
||||||
|
//
|
||||||
|
// The key controls the address outright — anyone holding it can move every
|
||||||
|
// token in it, from any device, forever — so this screen is handled under
|
||||||
|
// the same rules as the recovery phrase screen (./showPhrase.js):
|
||||||
|
//
|
||||||
|
// 1. Nothing is decrypted, no key is derived, and nothing is written into
|
||||||
|
// the DOM until decryptWithPassword has accepted the password.
|
||||||
|
// 2. Leaving the screen by any path wipes it, via the onViewLeave hook,
|
||||||
|
// and a decrypt still in flight when that happens is discarded
|
||||||
|
// instead of written (revealGeneration).
|
||||||
|
// 3. The key never reaches the logger. This module deliberately does not
|
||||||
|
// import src/shared/log.js.
|
||||||
|
//
|
||||||
|
// The key is also never assigned to `state`, so it cannot be persisted to
|
||||||
|
// extension storage, and "export-privkey" is excluded from RESTORABLE_VIEWS
|
||||||
|
// so the popup can never reopen onto it.
|
||||||
|
|
||||||
|
const {
|
||||||
|
$,
|
||||||
|
showView,
|
||||||
|
showFlash,
|
||||||
|
flashCopyFeedback,
|
||||||
|
goBack,
|
||||||
|
onViewLeave,
|
||||||
|
pushCurrentView,
|
||||||
|
renderAddressHtml,
|
||||||
|
attachCopyHandlers,
|
||||||
|
} = require("./helpers");
|
||||||
|
const { state } = require("../../shared/state");
|
||||||
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
|
const makeBlockie = require("ethereum-blockies-base64");
|
||||||
|
|
||||||
|
const VIEW = "export-privkey";
|
||||||
|
|
||||||
|
let walletIndex = null;
|
||||||
|
let addressIndex = null;
|
||||||
|
|
||||||
|
// Bumped by every clear(), which is what leaving the screen runs. reveal()
|
||||||
|
// captures it before awaiting the decrypt and refuses to touch the DOM if
|
||||||
|
// it has moved: a decrypt still in flight when the screen is left would
|
||||||
|
// otherwise write the key *after* the wipe, with nothing scheduled to wipe
|
||||||
|
// it again, leaving it in the hidden view for the life of the popup.
|
||||||
|
let revealGeneration = 0;
|
||||||
|
|
||||||
|
// True only if the reveal that captured `generation` is still the live one:
|
||||||
|
// the screen has not been left, cleared, or re-entered for another address
|
||||||
|
// since it started.
|
||||||
|
function isCurrentReveal(generation) {
|
||||||
|
return (
|
||||||
|
generation === revealGeneration &&
|
||||||
|
walletIndex !== null &&
|
||||||
|
addressIndex !== null &&
|
||||||
|
state.currentView === VIEW
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
$("export-privkey-flash").textContent = message;
|
||||||
|
$("export-privkey-flash").style.visibility = "visible";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wipe every trace of the key and drop the address selection. Safe to call
|
||||||
|
// when nothing was ever revealed, and safe to call twice.
|
||||||
|
function clear() {
|
||||||
|
walletIndex = null;
|
||||||
|
addressIndex = null;
|
||||||
|
revealGeneration += 1;
|
||||||
|
$("export-privkey-value").textContent = "";
|
||||||
|
$("export-privkey-password").value = "";
|
||||||
|
$("export-privkey-result").classList.add("hidden");
|
||||||
|
$("export-privkey-password-section").classList.remove("hidden");
|
||||||
|
$("export-privkey-flash").textContent = "";
|
||||||
|
$("export-privkey-flash").style.visibility = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(walletIdx, addrIdx) {
|
||||||
|
const wallet = state.wallets[walletIdx];
|
||||||
|
const addr = wallet && wallet.addresses[addrIdx];
|
||||||
|
if (!addr) {
|
||||||
|
showFlash("That address is no longer available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear();
|
||||||
|
walletIndex = walletIdx;
|
||||||
|
addressIndex = addrIdx;
|
||||||
|
|
||||||
|
const blockieEl = $("export-privkey-jazzicon");
|
||||||
|
blockieEl.innerHTML = "";
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = makeBlockie(addr.address);
|
||||||
|
img.width = 48;
|
||||||
|
img.height = 48;
|
||||||
|
img.style.imageRendering = "pixelated";
|
||||||
|
img.style.borderRadius = "50%";
|
||||||
|
blockieEl.appendChild(img);
|
||||||
|
|
||||||
|
$("export-privkey-title").textContent =
|
||||||
|
wallet.name + " — Address " + (addrIdx + 1);
|
||||||
|
const addrContainer = $("export-privkey-dot").parentElement;
|
||||||
|
addrContainer.innerHTML = renderAddressHtml(addr.address);
|
||||||
|
attachCopyHandlers(addrContainer);
|
||||||
|
|
||||||
|
// Pushed here rather than by the caller: this function can return
|
||||||
|
// without navigating, and a push that happened anyway would leave an
|
||||||
|
// entry on the stack that no screen transition matches.
|
||||||
|
pushCurrentView();
|
||||||
|
showView(VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reveal() {
|
||||||
|
const password = $("export-privkey-password").value;
|
||||||
|
if (!password) {
|
||||||
|
fail("Password is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (walletIndex === null) {
|
||||||
|
fail("No address is selected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wallet = state.wallets[walletIndex];
|
||||||
|
|
||||||
|
const btn = $("btn-export-privkey-confirm");
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.classList.add("text-muted");
|
||||||
|
const generation = revealGeneration;
|
||||||
|
try {
|
||||||
|
const secret = await decryptWithPassword(
|
||||||
|
wallet.encryptedSecret,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
// The only suspension point in this view, and the gate on the only
|
||||||
|
// place a secret is written: if the screen was left while the
|
||||||
|
// decrypt ran, the wipe has already happened, so the key is not
|
||||||
|
// even derived, let alone written.
|
||||||
|
if (!isCurrentReveal(generation)) return;
|
||||||
|
const signer = getSignerForAddress(wallet, addressIndex, secret);
|
||||||
|
$("export-privkey-password").value = "";
|
||||||
|
$("export-privkey-password-section").classList.add("hidden");
|
||||||
|
$("export-privkey-value").textContent = signer.privateKey;
|
||||||
|
$("export-privkey-result").classList.remove("hidden");
|
||||||
|
$("export-privkey-flash").textContent = "";
|
||||||
|
$("export-privkey-flash").style.visibility = "hidden";
|
||||||
|
} catch {
|
||||||
|
if (!isCurrentReveal(generation)) return;
|
||||||
|
fail("That password is not correct. Please try again.");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove("text-muted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
onViewLeave(VIEW, clear);
|
||||||
|
|
||||||
|
// No wipe here: goBack() routes through showView(), which runs the
|
||||||
|
// leave hook. A per-button wipe would only cover this one path.
|
||||||
|
$("btn-export-privkey-back").addEventListener("click", () => {
|
||||||
|
goBack();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-export-privkey-confirm").addEventListener("click", reveal);
|
||||||
|
|
||||||
|
$("export-privkey-value").addEventListener("click", () => {
|
||||||
|
const key = $("export-privkey-value").textContent;
|
||||||
|
if (!key) return;
|
||||||
|
navigator.clipboard.writeText(key);
|
||||||
|
showFlash("Copied!");
|
||||||
|
flashCopyFeedback($("export-privkey-value"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, show };
|
||||||
@@ -16,11 +16,36 @@ const { state, saveState, currentNetwork } = require("../../shared/state");
|
|||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
|
// Receipt poll cadence and the deadline after which the wait is reported as
|
||||||
|
// a timeout. Both are documented in the WaitTx section of README.md.
|
||||||
|
const POLL_INTERVAL_MS = 10000;
|
||||||
|
const TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
// How many receipt lookups may fail in a row before the wait is ended and
|
||||||
|
// the failure reported. A lookup that throws says nothing about the
|
||||||
|
// transaction, so one must not end the wait — but an RPC that never answers
|
||||||
|
// (a mistyped URL in settings is the ordinary case) must not leave the wait
|
||||||
|
// running forever either, least of all a persisted one that every popup
|
||||||
|
// open would resume. Six is 60 seconds at the poll cadence: the same
|
||||||
|
// patience the confirmation deadline gets. Any lookup that answers, with a
|
||||||
|
// receipt or with null, resets the count.
|
||||||
|
const MAX_CONSECUTIVE_LOOKUP_FAILURES = 6;
|
||||||
|
|
||||||
let ctx;
|
let ctx;
|
||||||
let elapsedTimer = null;
|
let elapsedTimer = null;
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
|
|
||||||
function clearTimers() {
|
// Identifies the wait currently on screen. Bumped by endWait(), so a timer
|
||||||
|
// callback or an in-flight receipt lookup that outlives its wait can tell
|
||||||
|
// that it is stale and leave the current view alone. Without it, a receipt
|
||||||
|
// resolving after the wait has ended renders over whatever view replaced it.
|
||||||
|
let waitId = 0;
|
||||||
|
|
||||||
|
// End the wait on screen: stop its timers and invalidate its pending async
|
||||||
|
// work. Called on receipt, on timeout, when a new wait starts, and when the
|
||||||
|
// user navigates away.
|
||||||
|
function endWait() {
|
||||||
|
waitId++;
|
||||||
if (elapsedTimer) {
|
if (elapsedTimer) {
|
||||||
clearInterval(elapsedTimer);
|
clearInterval(elapsedTimer);
|
||||||
elapsedTimer = null;
|
elapsedTimer = null;
|
||||||
@@ -47,8 +72,13 @@ function blockNumberHtml(blockNumber) {
|
|||||||
return copyableHtml(num) + etherscanLinkHtml(link);
|
return copyableHtml(num) + etherscanLinkHtml(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showWait(txInfo, txHash) {
|
// Render the wait view and start polling for the receipt. broadcastTime is
|
||||||
clearTimers();
|
// when the transaction was broadcast, which is what the elapsed counter and
|
||||||
|
// the timeout deadline are both measured from; pollNow runs one lookup
|
||||||
|
// immediately instead of waiting a full poll interval.
|
||||||
|
function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||||
|
endWait();
|
||||||
|
const id = waitId;
|
||||||
|
|
||||||
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||||
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
||||||
@@ -56,41 +86,130 @@ function showWait(txInfo, txHash) {
|
|||||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
|
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
|
||||||
attachCopyHandlers("view-wait-tx");
|
attachCopyHandlers("view-wait-tx");
|
||||||
|
|
||||||
const broadcastTime = Date.now();
|
// Persisted so closing and reopening the popup resumes this wait
|
||||||
$("wait-tx-status").textContent = "Waiting for confirmation... 0s";
|
// instead of silently abandoning it.
|
||||||
|
state.viewData = {
|
||||||
|
pendingWait: {
|
||||||
|
txInfo: txInfo,
|
||||||
|
hash: txHash,
|
||||||
|
broadcastTime: broadcastTime,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
elapsedTimer = setInterval(() => {
|
function renderElapsed() {
|
||||||
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
|
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
|
||||||
$("wait-tx-status").textContent =
|
$("wait-tx-status").textContent =
|
||||||
"Waiting for confirmation... " + elapsed + "s";
|
"Waiting for confirmation... " + elapsed + "s";
|
||||||
|
}
|
||||||
|
renderElapsed();
|
||||||
|
|
||||||
|
elapsedTimer = setInterval(() => {
|
||||||
|
if (id !== waitId) return;
|
||||||
|
renderElapsed();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const provider = getProvider(state.rpcUrl);
|
const provider = getProvider(state.rpcUrl);
|
||||||
pollTimer = setInterval(async () => {
|
let consecutiveFailures = 0;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (id !== waitId) return;
|
||||||
|
let receipt = null;
|
||||||
|
let answered = true;
|
||||||
try {
|
try {
|
||||||
const receipt = await provider.getTransactionReceipt(txHash);
|
receipt = await provider.getTransactionReceipt(txHash);
|
||||||
if (receipt) {
|
|
||||||
showSuccess(txInfo, txHash, receipt.blockNumber);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// A thrown lookup means "no answer this tick", not "no
|
||||||
|
// receipt": the RPC failed, the chain said nothing. Declaring
|
||||||
|
// the timeout off it would report a confirmed transaction as
|
||||||
|
// failed — which matters most on a resumed wait, where the
|
||||||
|
// first poll is already past the deadline.
|
||||||
|
answered = false;
|
||||||
log.errorf("poll receipt failed:", e.message);
|
log.errorf("poll receipt failed:", e.message);
|
||||||
}
|
}
|
||||||
|
// The lookup is async: the wait may have ended while it was in
|
||||||
const elapsed = Math.floor((Date.now() - broadcastTime) / 1000);
|
// flight, in which case this result must not touch the view.
|
||||||
if (elapsed >= 60) {
|
if (id !== waitId) return;
|
||||||
|
// Exactly one outcome per wait. A receipt wins even on the tick
|
||||||
|
// that crosses the deadline, because the transaction did confirm.
|
||||||
|
if (receipt) {
|
||||||
|
showSuccess(txInfo, txHash, receipt.blockNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!answered) {
|
||||||
|
consecutiveFailures++;
|
||||||
|
// The failure is the user's news, and it is a different fact
|
||||||
|
// from "the transaction did not confirm" — the chain was never
|
||||||
|
// asked. Ending the wait here is what keeps it bounded and
|
||||||
|
// gives the user a Done button to leave by.
|
||||||
|
if (consecutiveFailures >= MAX_CONSECUTIVE_LOOKUP_FAILURES) {
|
||||||
|
showError(
|
||||||
|
txInfo,
|
||||||
|
txHash,
|
||||||
|
"The network could not be reached to check this transaction — " +
|
||||||
|
MAX_CONSECUTIVE_LOOKUP_FAILURES +
|
||||||
|
" lookups failed in a row. Check the RPC URL in Settings. The transaction may still have confirmed — check Etherscan.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Otherwise keep polling: the next tick may answer.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
consecutiveFailures = 0;
|
||||||
|
if (Date.now() - broadcastTime >= TIMEOUT_MS) {
|
||||||
showError(
|
showError(
|
||||||
txInfo,
|
txInfo,
|
||||||
txHash,
|
txHash,
|
||||||
"Transaction was not confirmed within 60 seconds. It may still confirm later \u2014 check Etherscan.",
|
"Transaction was not confirmed within 60 seconds. It may still confirm later \u2014 check Etherscan.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, 10000);
|
}
|
||||||
|
|
||||||
|
pollTimer = setInterval(poll, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
showView("wait-tx");
|
showView("wait-tx");
|
||||||
|
|
||||||
|
if (pollNow) poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWait(txInfo, txHash) {
|
||||||
|
startWait(txInfo, txHash, Date.now(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume a wait persisted by a previous popup session. The deadline still
|
||||||
|
// runs from the original broadcast, so a wait that has already outlived it
|
||||||
|
// resolves on the immediate first poll rather than restarting the clock.
|
||||||
|
// Returns false when there is nothing resumable to resume. Every field
|
||||||
|
// startWait() goes on to use is validated, not just the presence of the
|
||||||
|
// containers: txInfo.to reaches addressTitle(), which calls
|
||||||
|
// address.toLowerCase(), and txInfo.amount is rendered into the summary, so
|
||||||
|
// an object merely missing one of them throws a TypeError out of
|
||||||
|
// restoreView() — which init() does not guard, skipping the rest of popup
|
||||||
|
// init and leaving wait-tx on screen with no back control. A non-numeric
|
||||||
|
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and
|
||||||
|
// txInfo.tokenSymbol are deliberately unchecked: they are compared and
|
||||||
|
// coalesced rather than dereferenced, and tokenSymbol is null for ETH.
|
||||||
|
function restoreWait() {
|
||||||
|
const d = state.viewData;
|
||||||
|
if (!d || !d.pendingWait) return false;
|
||||||
|
const w = d.pendingWait;
|
||||||
|
if (!w.hash) return false;
|
||||||
|
// typeof [] is "object", so an array passes an object check.
|
||||||
|
const info = w.txInfo;
|
||||||
|
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
|
||||||
|
// A string is the whole requirement: the empty string is what a
|
||||||
|
// contract-deployment approval persists (approval.js writes `to: toAddr
|
||||||
|
// || ""`), and both fields render harmlessly when empty, so refusing it
|
||||||
|
// would abandon a wait the live path itself created.
|
||||||
|
if (typeof info.to !== "string") return false;
|
||||||
|
if (typeof info.amount !== "string") return false;
|
||||||
|
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
startWait(w.txInfo, w.hash, w.broadcastTime, true);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSuccess(txInfo, txHash, blockNumber) {
|
function showSuccess(txInfo, txHash, blockNumber) {
|
||||||
clearTimers();
|
endWait();
|
||||||
|
|
||||||
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||||
state.viewData = {
|
state.viewData = {
|
||||||
@@ -182,7 +301,7 @@ function renderSuccess() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showError(txInfo, txHash, message) {
|
function showError(txInfo, txHash, message) {
|
||||||
clearTimers();
|
endWait();
|
||||||
|
|
||||||
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
|
||||||
state.viewData = {
|
state.viewData = {
|
||||||
@@ -218,6 +337,9 @@ function isApprovalPopup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateBack() {
|
function navigateBack() {
|
||||||
|
// Nothing should still be polling by now, but leaving a view is the
|
||||||
|
// point at which its timers must be gone.
|
||||||
|
endWait();
|
||||||
if (isApprovalPopup()) {
|
if (isApprovalPopup()) {
|
||||||
window.close();
|
window.close();
|
||||||
return;
|
return;
|
||||||
@@ -242,4 +364,12 @@ function init(_ctx) {
|
|||||||
$("btn-error-tx-done").addEventListener("click", navigateBack);
|
$("btn-error-tx-done").addEventListener("click", navigateBack);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, showWait, showError, renderSuccess, renderError };
|
module.exports = {
|
||||||
|
init,
|
||||||
|
showWait,
|
||||||
|
restoreWait,
|
||||||
|
endWait,
|
||||||
|
showError,
|
||||||
|
renderSuccess,
|
||||||
|
renderError,
|
||||||
|
};
|
||||||
|
|||||||
@@ -345,8 +345,15 @@ class Driver {
|
|||||||
// built on Playwright-BiDi or Puppeteer-BiDi therefore sees nothing and
|
// built on Playwright-BiDi or Puppeteer-BiDi therefore sees nothing and
|
||||||
// reports success. Do not "simplify" this back to BiDi.
|
// reports success. Do not "simplify" this back to BiDi.
|
||||||
//
|
//
|
||||||
// nsIConsoleService sees everything, including the background page and
|
// nsIConsoleService is not per-page: it also carries errors from the
|
||||||
// content scripts, which BiDi would not have covered even if it worked.
|
// background page, which BiDi would not have covered even if it worked.
|
||||||
|
// Background-page capture is verified by probe — a throw at the top of
|
||||||
|
// src/background/index.js, which kills the background page outright, fails
|
||||||
|
// the run. Content-script errors should arrive by the same route, but that
|
||||||
|
// is UNVERIFIED here and must not be claimed: the container runs with
|
||||||
|
// --network none, so there is no http:// page for a content script to be
|
||||||
|
// injected into and this suite never exercises one.
|
||||||
|
//
|
||||||
// Warnings are excluded so the semantics match Playwright's pageerror:
|
// Warnings are excluded so the semantics match Playwright's pageerror:
|
||||||
// uncaught errors only.
|
// uncaught errors only.
|
||||||
const READ_ERRORS_SCRIPT = `
|
const READ_ERRORS_SCRIPT = `
|
||||||
@@ -390,6 +397,10 @@ class ConsoleErrors {
|
|||||||
return found || [];
|
return found || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discards the console outright. Anything logged and not yet taken is
|
||||||
|
// destroyed unread, so this is only ever correct straight after a
|
||||||
|
// take(); to move past a window of errors, take() them and report
|
||||||
|
// them somewhere.
|
||||||
async reset() {
|
async reset() {
|
||||||
await this.driver.executeChrome("Services.console.reset(); return 0;");
|
await this.driver.executeChrome("Services.console.reset(); return 0;");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,13 @@
|
|||||||
// LIMITATION, and the difference from the Chrome suite worth knowing: error
|
// LIMITATION, and the difference from the Chrome suite worth knowing: error
|
||||||
// capture here is POLL-BASED, not event-streamed. The console service is
|
// capture here is POLL-BASED, not event-streamed. The console service is
|
||||||
// drained at each step boundary, so an error is attributed to the step it
|
// drained at each step boundary, so an error is attributed to the step it
|
||||||
// was drained after — never lost, but never located within the step
|
// was drained after, never to a moment within that step. What is drained
|
||||||
// either. The Chrome harness receives pageerror events as they happen and
|
// covers the whole run from add-on install to one second after the last
|
||||||
// can say more. Do not read a green Firefox run as the same claim.
|
// step returns — but only that far: an error logged more than that ~1s
|
||||||
|
// tail after the last step is never observed at all, because the browser
|
||||||
|
// is torn down first. The Chrome harness receives pageerror events as they
|
||||||
|
// happen and can say more. Do not read a green Firefox run as the same
|
||||||
|
// claim.
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -178,9 +182,20 @@ async function main() {
|
|||||||
let failed = 0;
|
let failed = 0;
|
||||||
let n = 0;
|
let n = 0;
|
||||||
try {
|
try {
|
||||||
// Anything the add-on logged while installing and starting its
|
// Drain, never reset: anything the add-on logged while installing
|
||||||
// background page belongs to step 1 rather than to nowhere.
|
// and starting its background page has no earlier step to belong
|
||||||
await errors.reset();
|
// to, so it is folded into step 1 below. Services.console.reset()
|
||||||
|
// here would DELETE it instead, and a background page that throws
|
||||||
|
// at the top of the file — a dead background page — would then
|
||||||
|
// produce a fully green run.
|
||||||
|
let installErrors = [];
|
||||||
|
let installFailure = null;
|
||||||
|
try {
|
||||||
|
installErrors = await errors.take();
|
||||||
|
} catch (e) {
|
||||||
|
installFailure =
|
||||||
|
"could not read the console after install: " + e.message;
|
||||||
|
}
|
||||||
|
|
||||||
for (const s of steps) {
|
for (const s of steps) {
|
||||||
n += 1;
|
n += 1;
|
||||||
@@ -204,10 +219,21 @@ async function main() {
|
|||||||
failure = failure || "could not read the console: " + e.message;
|
failure = failure || "could not read the console: " + e.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (n === 1) {
|
||||||
|
found = installErrors.concat(found);
|
||||||
|
installErrors = [];
|
||||||
|
failure = failure || installFailure;
|
||||||
|
installFailure = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Any uncaught error from an extension source fails the step
|
// Any uncaught error from an extension source fails the step
|
||||||
// that provoked it, whether or not its assertions passed.
|
// that provoked it, whether or not its assertions passed.
|
||||||
if (!failure && found.length > 0) {
|
if (!failure && found.length > 0) {
|
||||||
failure = "uncaught extension errors during this step";
|
failure =
|
||||||
|
n === 1
|
||||||
|
? "uncaught extension errors during add-on install, " +
|
||||||
|
"background startup or this step"
|
||||||
|
: "uncaught extension errors during this step";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failure) {
|
if (failure) {
|
||||||
|
|||||||
331
tests/exportPrivkey.test.js
Normal file
331
tests/exportPrivkey.test.js
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
// Tests for the private key export screen (issue #221).
|
||||||
|
//
|
||||||
|
// The screen holds the one secret that owns an address outright, so what is
|
||||||
|
// pinned here is disposal: the key is wiped from the DOM whenever the screen
|
||||||
|
// is left by any route, and a decrypt still in flight when the screen is
|
||||||
|
// left never writes at all. That last case is the one a per-button wipe and
|
||||||
|
// a naive leave hook both miss — the write lands after the wipe, with
|
||||||
|
// nothing scheduled to wipe it again.
|
||||||
|
//
|
||||||
|
// The view is driven against a minimal DOM stub rather than a real browser:
|
||||||
|
// the module is deliberately shaped like src/popup/views/showPhrase.js, with
|
||||||
|
// no dependency that needs a document beyond the nodes it reads and writes.
|
||||||
|
|
||||||
|
const mockPrivateKey = "0x" + "ab".repeat(32);
|
||||||
|
|
||||||
|
jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
|
||||||
|
jest.mock("../src/shared/vault", () => ({
|
||||||
|
decryptWithPassword: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("../src/shared/wallet", () => ({
|
||||||
|
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||||
|
|
||||||
|
const VIEW = "export-privkey";
|
||||||
|
const PASSWORD = "correct horse battery";
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ DOM stub
|
||||||
|
|
||||||
|
function makeElement(id, withParent) {
|
||||||
|
const classes = new Set();
|
||||||
|
const el = {
|
||||||
|
id,
|
||||||
|
textContent: "",
|
||||||
|
value: "",
|
||||||
|
innerHTML: "",
|
||||||
|
disabled: false,
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
listeners: {},
|
||||||
|
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: (name, fn) => {
|
||||||
|
el.listeners[name] = el.listeners[name] || [];
|
||||||
|
el.listeners[name].push(fn);
|
||||||
|
},
|
||||||
|
appendChild: () => {},
|
||||||
|
remove: () => {},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
};
|
||||||
|
el.parentElement = withParent ? makeElement(id + "-parent", false) : null;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDocument() {
|
||||||
|
const els = new Map();
|
||||||
|
return {
|
||||||
|
getElementById(id) {
|
||||||
|
// The debug banner is created on demand by helpers.js; absent
|
||||||
|
// is the state a non-debug, non-testnet popup is in.
|
||||||
|
if (id === "debug-banner") return null;
|
||||||
|
if (!els.has(id)) els.set(id, makeElement(id, true));
|
||||||
|
return els.get(id);
|
||||||
|
},
|
||||||
|
createElement: () => makeElement("created", false),
|
||||||
|
addEventListener: () => {},
|
||||||
|
body: { prepend: () => {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ harness
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
jest.resetModules();
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||||
|
};
|
||||||
|
globalThis.document = makeDocument();
|
||||||
|
|
||||||
|
const helpers = require("../src/popup/views/helpers");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const vault = require("../src/shared/vault");
|
||||||
|
const wallet = require("../src/shared/wallet");
|
||||||
|
const exportPrivkey = require("../src/popup/views/exportPrivkey");
|
||||||
|
|
||||||
|
state.wallets = [
|
||||||
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
type: "key",
|
||||||
|
encryptedSecret: "ciphertext",
|
||||||
|
addresses: [
|
||||||
|
{
|
||||||
|
address: "0x" + "11".repeat(20),
|
||||||
|
balance: "0.0000",
|
||||||
|
tokenBalances: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
address: "0x" + "22".repeat(20),
|
||||||
|
balance: "0.0000",
|
||||||
|
tokenBalances: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.viewStack = [];
|
||||||
|
state.currentView = "address";
|
||||||
|
|
||||||
|
exportPrivkey.init();
|
||||||
|
return { helpers, state, vault, wallet, exportPrivkey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(id) {
|
||||||
|
const el = globalThis.document.getElementById(id);
|
||||||
|
return Promise.all((el.listeners.click || []).map((fn) => fn()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id) {
|
||||||
|
return globalThis.document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a reveal and hand back both the promise it returns and the resolver
|
||||||
|
// for the decrypt it is waiting on, so a test can navigate away mid-flight.
|
||||||
|
function startReveal(vault) {
|
||||||
|
let resolveDecrypt;
|
||||||
|
let rejectDecrypt;
|
||||||
|
vault.decryptWithPassword.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
resolveDecrypt = resolve;
|
||||||
|
rejectDecrypt = reject;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
node("export-privkey-password").value = PASSWORD;
|
||||||
|
const pending = click("btn-export-privkey-confirm");
|
||||||
|
return {
|
||||||
|
pending,
|
||||||
|
resolve: (v) => resolveDecrypt(v),
|
||||||
|
reject: (e) => rejectDecrypt(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ tests
|
||||||
|
|
||||||
|
describe("a decrypt still running when the screen is left", () => {
|
||||||
|
// The load-bearing case. Without the liveness guard in reveal(), the
|
||||||
|
// write lands after the leave hook has already wiped, and the key sits
|
||||||
|
// in the hidden view for the life of the popup.
|
||||||
|
test("never writes the key into the DOM", async () => {
|
||||||
|
const { helpers, vault, wallet, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const reveal = startReveal(vault);
|
||||||
|
// The settings gear, mid-decrypt.
|
||||||
|
helpers.showView("settings");
|
||||||
|
reveal.resolve("wallet secret");
|
||||||
|
await reveal.pending;
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
// Nothing was even derived: the guard sits in front of the
|
||||||
|
// derivation, not just in front of the write.
|
||||||
|
expect(wallet.getSignerForAddress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The generation counter, not merely the current-view check: by the time
|
||||||
|
// the stale decrypt resolves the user is back on the screen, so a guard
|
||||||
|
// that only asked "is this view showing?" would let the write through.
|
||||||
|
test("never writes it after the screen is re-entered", async () => {
|
||||||
|
const { helpers, vault, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const stale = startReveal(vault);
|
||||||
|
helpers.showView("settings");
|
||||||
|
exportPrivkey.show(0, 1);
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
|
||||||
|
stale.resolve("wallet secret");
|
||||||
|
await stale.pending;
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same hole on the failure path: a wrong-password error written after
|
||||||
|
// the wipe would restore the flash line on a screen the user has left.
|
||||||
|
test("never writes the failure message either", async () => {
|
||||||
|
const { helpers, vault, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const reveal = startReveal(vault);
|
||||||
|
helpers.showView("settings");
|
||||||
|
reveal.reject(new Error("decryption failed"));
|
||||||
|
await reveal.pending;
|
||||||
|
|
||||||
|
expect(node("export-privkey-flash").textContent).toBe("");
|
||||||
|
expect(node("export-privkey-flash").style.visibility).toBe("hidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a reveal that is not interrupted", () => {
|
||||||
|
// Guards the guard: a liveness check that rejected every write would
|
||||||
|
// pass every test above and ship a screen that reveals nothing.
|
||||||
|
test("puts the key on screen", async () => {
|
||||||
|
const { vault, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const reveal = startReveal(vault);
|
||||||
|
reveal.resolve("wallet secret");
|
||||||
|
await reveal.pending;
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
|
||||||
|
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// The password is dropped as soon as it has been spent.
|
||||||
|
expect(node("export-privkey-password").value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes nothing before the password is accepted", async () => {
|
||||||
|
const { vault, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const reveal = startReveal(vault);
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
reveal.resolve("wallet secret");
|
||||||
|
await reveal.pending;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reveals nothing when the password is wrong", async () => {
|
||||||
|
const { vault, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
const reveal = startReveal(vault);
|
||||||
|
reveal.reject(new Error("decryption failed"));
|
||||||
|
await reveal.pending;
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
expect(node("export-privkey-flash").textContent).toBe(
|
||||||
|
"That password is not correct. Please try again.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("leaving the screen after the key is on it", () => {
|
||||||
|
async function revealed() {
|
||||||
|
const loaded = load();
|
||||||
|
loaded.exportPrivkey.show(0, 0);
|
||||||
|
const reveal = startReveal(loaded.vault);
|
||||||
|
reveal.resolve("wallet secret");
|
||||||
|
await reveal.pending;
|
||||||
|
expect(node("export-privkey-value").textContent).toBe(mockPrivateKey);
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the Back button clears the key", async () => {
|
||||||
|
await revealed();
|
||||||
|
await click("btn-export-privkey-back");
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
expect(node("export-privkey-password").value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the settings gear clears the key", async () => {
|
||||||
|
const { helpers } = await revealed();
|
||||||
|
helpers.showView("settings");
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
expect(node("export-privkey-password").value).toBe("");
|
||||||
|
// And the screen is back to its password prompt, not to a result
|
||||||
|
// panel that would flash an empty well on the next visit.
|
||||||
|
expect(node("export-privkey-result").classList.contains("hidden")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
node("export-privkey-password-section").classList.contains(
|
||||||
|
"hidden",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any other navigation: the same hook covers routes that do not exist
|
||||||
|
// yet, which is the point of registering it on the view rather than on
|
||||||
|
// the controls that leave it.
|
||||||
|
test("any other navigation clears the key", async () => {
|
||||||
|
const { helpers } = await revealed();
|
||||||
|
helpers.showView("main");
|
||||||
|
|
||||||
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("views the popup may reopen onto", () => {
|
||||||
|
// Restoring onto this screen would put a private key on display with no
|
||||||
|
// password prompt in front of it, on a popup reopened by accident.
|
||||||
|
test("the private key export screen is not restorable", () => {
|
||||||
|
expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it is still a registered view", () => {
|
||||||
|
const { helpers } = load();
|
||||||
|
expect(helpers.VIEWS).toContain(VIEW);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the key cannot reach the logger", () => {
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(__dirname, "..", "src", "popup", "views", "exportPrivkey.js"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("the view does not import src/shared/log.js", () => {
|
||||||
|
expect(source).not.toMatch(/require\(["'][^"']*shared\/log["']\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the view calls no logger method", () => {
|
||||||
|
expect(source).not.toMatch(/\blog\.(debugf|infof|warnf|errorf)\b/);
|
||||||
|
});
|
||||||
|
});
|
||||||
482
tests/txStatus.test.js
Normal file
482
tests/txStatus.test.js
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
// Lifecycle tests for the post-broadcast transaction status views
|
||||||
|
// (src/popup/views/txStatus.js).
|
||||||
|
//
|
||||||
|
// The bug these pin down: the receipt poll rendered both outcomes on the tick
|
||||||
|
// that crossed the 60-second deadline, so a confirmed transaction was replaced
|
||||||
|
// by "not confirmed within 60 seconds" — the user is told their transaction
|
||||||
|
// failed when it succeeded. The same shape applies to any callback that
|
||||||
|
// outlives its wait: a receipt lookup still in flight when the view is left
|
||||||
|
// must not render over whatever replaced it.
|
||||||
|
//
|
||||||
|
// Fake timers make the race deterministic: the receipt promise is already
|
||||||
|
// resolved when the deadline tick runs, so in the unfixed code showSuccess()
|
||||||
|
// is always followed by showError() on that tick.
|
||||||
|
//
|
||||||
|
// No network: getProvider is mocked at the module boundary and there is no
|
||||||
|
// jsdom in this repo, so the handful of DOM calls these views make are served
|
||||||
|
// by the stub below.
|
||||||
|
|
||||||
|
jest.mock("../src/shared/log", () => ({
|
||||||
|
log: {
|
||||||
|
debugf: () => {},
|
||||||
|
infof: () => {},
|
||||||
|
warnf: () => {},
|
||||||
|
errorf: () => {},
|
||||||
|
},
|
||||||
|
debugFetch: jest.fn(),
|
||||||
|
setRuntimeDebug: () => {},
|
||||||
|
isDebug: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockReceiptLookup = jest.fn();
|
||||||
|
jest.mock("../src/shared/balances", () => ({
|
||||||
|
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
|
||||||
|
refreshBalances: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.fetch = jest.fn(() => {
|
||||||
|
throw new Error("tests must not perform network requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal DOM. Every element is created on demand and remembered by id, so a
|
||||||
|
// test can read back what a view wrote into it.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const elements = new Map();
|
||||||
|
|
||||||
|
function makeElement(id) {
|
||||||
|
const classes = new Set(["view", "hidden"]);
|
||||||
|
const el = {
|
||||||
|
id,
|
||||||
|
textContent: "",
|
||||||
|
innerHTML: "",
|
||||||
|
style: {},
|
||||||
|
classList: {
|
||||||
|
add: (c) => classes.add(c),
|
||||||
|
remove: (c) => classes.delete(c),
|
||||||
|
contains: (c) => classes.has(c),
|
||||||
|
toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)),
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
remove: () => {},
|
||||||
|
prepend: () => {},
|
||||||
|
};
|
||||||
|
// Views reach for .parentElement to hide whole sections.
|
||||||
|
Object.defineProperty(el, "parentElement", {
|
||||||
|
get: () => getElement(id + "-parent"),
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElement(id) {
|
||||||
|
if (!elements.has(id)) elements.set(id, makeElement(id));
|
||||||
|
return elements.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
global.document = {
|
||||||
|
getElementById: (id) => getElement(id),
|
||||||
|
// escapeHtml() builds a detached div; textContent in, escaped HTML out.
|
||||||
|
createElement: () => {
|
||||||
|
const el = { innerHTML: "" };
|
||||||
|
Object.defineProperty(el, "textContent", {
|
||||||
|
set(v) {
|
||||||
|
el.innerHTML = String(v)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
body: { prepend: () => {} },
|
||||||
|
addEventListener: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
global.window = { location: { search: "" } };
|
||||||
|
|
||||||
|
const stored = {};
|
||||||
|
global.chrome = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
set: (obj) => {
|
||||||
|
Object.assign(stored, obj);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
get: () => Promise.resolve(stored),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const txStatus = require("../src/popup/views/txStatus");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||||
|
|
||||||
|
const TX_HASH =
|
||||||
|
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
||||||
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
const TX_INFO = {
|
||||||
|
to: RECIPIENT,
|
||||||
|
amount: "0.0050",
|
||||||
|
token: "ETH",
|
||||||
|
tokenSymbol: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// True when a view element is not hidden.
|
||||||
|
function visible(view) {
|
||||||
|
return !getElement("view-" + view).classList.contains("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitStatusText() {
|
||||||
|
return getElement("wait-tx-status").textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
|
||||||
|
elements.clear();
|
||||||
|
mockReceiptLookup.mockReset();
|
||||||
|
state.wallets = [];
|
||||||
|
state.viewData = {};
|
||||||
|
state.viewStack = [];
|
||||||
|
state.currentView = null;
|
||||||
|
txStatus.init({ doRefreshAndRender: jest.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
txStatus.endWait();
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WaitTx receipt/timeout race", () => {
|
||||||
|
test("a receipt arriving on the deadline tick leaves the user on SuccessTx", async () => {
|
||||||
|
// No receipt for the first five polls; the sixth — the tick at
|
||||||
|
// t=60s, which is also the timeout deadline — returns one.
|
||||||
|
mockReceiptLookup
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValue({ blockNumber: 21000000 });
|
||||||
|
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
expect(visible("wait-tx")).toBe(true);
|
||||||
|
|
||||||
|
await jest.advanceTimersByTimeAsync(60000);
|
||||||
|
|
||||||
|
expect(visible("success-tx")).toBe(true);
|
||||||
|
expect(visible("error-tx")).toBe(false);
|
||||||
|
expect(state.currentView).toBe("success-tx");
|
||||||
|
expect(state.viewData.blockNumber).toBe(21000000);
|
||||||
|
expect(state.viewData.message).toBeUndefined();
|
||||||
|
|
||||||
|
// And nothing is left running to undo it.
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
await jest.advanceTimersByTimeAsync(300000);
|
||||||
|
expect(state.currentView).toBe("success-tx");
|
||||||
|
expect(mockReceiptLookup).toHaveBeenCalledTimes(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a genuine timeout still shows ErrorTx with the hash", async () => {
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
await jest.advanceTimersByTimeAsync(60000);
|
||||||
|
|
||||||
|
expect(visible("error-tx")).toBe(true);
|
||||||
|
expect(state.currentView).toBe("error-tx");
|
||||||
|
expect(state.viewData.message).toMatch(
|
||||||
|
/not confirmed within 60 seconds/,
|
||||||
|
);
|
||||||
|
expect(state.viewData.hash).toBe(TX_HASH);
|
||||||
|
// The hash section carries the hash and the etherscan link.
|
||||||
|
expect(getElement("error-tx-hash").innerHTML).toContain(TX_HASH);
|
||||||
|
expect(getElement("error-tx-hash").innerHTML).toContain(
|
||||||
|
"/tx/" + TX_HASH,
|
||||||
|
);
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a receipt still in flight when the view is left does not render over it", async () => {
|
||||||
|
let resolveReceipt;
|
||||||
|
mockReceiptLookup.mockReturnValue(
|
||||||
|
new Promise((r) => {
|
||||||
|
resolveReceipt = r;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
await jest.advanceTimersByTimeAsync(10000);
|
||||||
|
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// User leaves the wait (popup navigation / teardown) while the
|
||||||
|
// lookup is outstanding, then the lookup finally answers.
|
||||||
|
txStatus.endWait();
|
||||||
|
state.currentView = "main";
|
||||||
|
resolveReceipt({ blockNumber: 21000000 });
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(state.currentView).toBe("main");
|
||||||
|
expect(visible("success-tx")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no timer survives the view being left", async () => {
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
txStatus.endWait();
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
|
||||||
|
await jest.advanceTimersByTimeAsync(120000);
|
||||||
|
expect(mockReceiptLookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WaitTx persistence across popup close", () => {
|
||||||
|
test("restoreWait resumes the poll with the deadline running from broadcast", async () => {
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
expect(state.viewData.pendingWait.hash).toBe(TX_HASH);
|
||||||
|
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||||
|
|
||||||
|
// Popup closes: timers die with the page.
|
||||||
|
txStatus.endWait();
|
||||||
|
|
||||||
|
// 45 seconds pass with the popup shut, then it is reopened.
|
||||||
|
jest.advanceTimersByTime(45000);
|
||||||
|
state.viewData = persisted;
|
||||||
|
expect(txStatus.restoreWait()).toBe(true);
|
||||||
|
|
||||||
|
expect(visible("wait-tx")).toBe(true);
|
||||||
|
// Elapsed is counted from the broadcast, not from the reopen.
|
||||||
|
expect(waitStatusText()).toBe("Waiting for confirmation... 45s");
|
||||||
|
// The immediate poll on resume has already run.
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The deadline is 15 seconds away, not 60.
|
||||||
|
await jest.advanceTimersByTimeAsync(20000);
|
||||||
|
expect(state.currentView).toBe("error-tx");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rejected lookup on the resume poll keeps waiting instead of reporting failure", async () => {
|
||||||
|
// A wait resumed after the deadline has already passed: the first
|
||||||
|
// poll is immediate and past 60s, so a thrown lookup must not be
|
||||||
|
// read as "no receipt". It means "no answer this tick" — keep
|
||||||
|
// polling, because the transaction may well have confirmed.
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||||
|
txStatus.endWait();
|
||||||
|
|
||||||
|
// Ten minutes with the popup shut, then it is reopened and the
|
||||||
|
// first receipt lookup fails transiently.
|
||||||
|
jest.advanceTimersByTime(600000);
|
||||||
|
mockReceiptLookup.mockReset();
|
||||||
|
mockReceiptLookup
|
||||||
|
.mockRejectedValueOnce(new Error("rpc unavailable"))
|
||||||
|
.mockResolvedValue({ blockNumber: 21000000 });
|
||||||
|
|
||||||
|
state.viewData = persisted;
|
||||||
|
expect(txStatus.restoreWait()).toBe(true);
|
||||||
|
await jest.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
// The wait is still alive: no timeout was declared off one error.
|
||||||
|
expect(visible("wait-tx")).toBe(true);
|
||||||
|
expect(visible("error-tx")).toBe(false);
|
||||||
|
expect(state.currentView).toBe("wait-tx");
|
||||||
|
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// And the next tick answers, so the confirmed transaction is
|
||||||
|
// reported as confirmed.
|
||||||
|
await jest.advanceTimersByTimeAsync(10000);
|
||||||
|
expect(state.currentView).toBe("success-tx");
|
||||||
|
expect(state.viewData.blockNumber).toBe(21000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a lookup returning null past the deadline still times out", async () => {
|
||||||
|
// The counterpart to the test above: the deadline must still fire
|
||||||
|
// when the lookup actually answers "no receipt".
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||||
|
txStatus.endWait();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(600000);
|
||||||
|
state.viewData = persisted;
|
||||||
|
expect(txStatus.restoreWait()).toBe(true);
|
||||||
|
await jest.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(state.currentView).toBe("error-tx");
|
||||||
|
expect(state.viewData.message).toMatch(
|
||||||
|
/not confirmed within 60 seconds/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restoreWait reports nothing to resume when no wait is persisted", () => {
|
||||||
|
state.viewData = {};
|
||||||
|
expect(txStatus.restoreWait()).toBe(false);
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restoreWait rejects a persisted wait missing its txInfo or broadcast time", () => {
|
||||||
|
for (const bad of [
|
||||||
|
{ hash: TX_HASH, broadcastTime: Date.now() },
|
||||||
|
{ hash: TX_HASH, txInfo: TX_INFO },
|
||||||
|
{ hash: TX_HASH, txInfo: TX_INFO, broadcastTime: "soon" },
|
||||||
|
{ hash: TX_HASH, txInfo: TX_INFO, broadcastTime: NaN },
|
||||||
|
{ hash: TX_HASH, txInfo: "nope", broadcastTime: Date.now() },
|
||||||
|
// An object that merely lacks a field startWait() dereferences
|
||||||
|
// is the shape that actually escaped: txInfo.to reaches
|
||||||
|
// addressTitle(), which calls address.toLowerCase(). typeof []
|
||||||
|
// is "object", so an array passes an object check.
|
||||||
|
{ hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() },
|
||||||
|
{ hash: TX_HASH, txInfo: [], broadcastTime: Date.now() },
|
||||||
|
{ hash: TX_HASH, txInfo: { to: 42 }, broadcastTime: Date.now() },
|
||||||
|
// Otherwise complete but for a non-string `to`: only the `to`
|
||||||
|
// check rejects this one, and without it addressTitle() throws
|
||||||
|
// out of restoreView().
|
||||||
|
{
|
||||||
|
hash: TX_HASH,
|
||||||
|
txInfo: { to: 42, amount: "0.0050" },
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
|
// Otherwise complete but an array: only Array.isArray() rejects
|
||||||
|
// it, since typeof [] is "object" and the fields are present.
|
||||||
|
{
|
||||||
|
hash: TX_HASH,
|
||||||
|
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hash: TX_HASH,
|
||||||
|
txInfo: { to: RECIPIENT },
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
state.viewData = { pendingWait: bad };
|
||||||
|
expect(txStatus.restoreWait()).toBe(false);
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restoreWait resumes a wait whose recipient is the empty string", () => {
|
||||||
|
// The shape a contract-deployment approval persists: approval.js
|
||||||
|
// writes `to: toAddr || ""`, and showWait() renders it without
|
||||||
|
// complaint. Validation must not be stricter than the live path, or
|
||||||
|
// that wait is silently abandoned on every popup open.
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
state.viewData = {
|
||||||
|
pendingWait: {
|
||||||
|
hash: TX_HASH,
|
||||||
|
txInfo: { ...TX_INFO, to: "" },
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(txStatus.restoreWait()).toBe(true);
|
||||||
|
expect(visible("wait-tx")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WaitTx against an RPC that never answers", () => {
|
||||||
|
test("a permanently failing lookup ends the wait instead of polling forever", async () => {
|
||||||
|
mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable"));
|
||||||
|
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
|
||||||
|
// Six consecutive failures is 60 seconds at the 10s cadence — the
|
||||||
|
// same patience as the confirmation deadline.
|
||||||
|
await jest.advanceTimersByTimeAsync(60000);
|
||||||
|
|
||||||
|
expect(state.currentView).toBe("error-tx");
|
||||||
|
expect(visible("wait-tx")).toBe(false);
|
||||||
|
// The user is told what actually happened: the lookup failed. It is
|
||||||
|
// not the same fact as "the transaction did not confirm".
|
||||||
|
expect(state.viewData.message).toMatch(/could not be reached/i);
|
||||||
|
expect(state.viewData.message).not.toMatch(/not confirmed within/);
|
||||||
|
expect(state.viewData.hash).toBe(TX_HASH);
|
||||||
|
|
||||||
|
// Nothing is left running, and nothing is left to resume onto.
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
expect(state.viewData.pendingWait).toBeUndefined();
|
||||||
|
|
||||||
|
const calls = mockReceiptLookup.mock.calls.length;
|
||||||
|
await jest.advanceTimersByTimeAsync(3600000);
|
||||||
|
expect(mockReceiptLookup).toHaveBeenCalledTimes(calls);
|
||||||
|
expect(state.currentView).toBe("error-tx");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answered lookup clears the failure count, so the bound is on consecutive failures", async () => {
|
||||||
|
// The bound counts failures in a row, not failures in total: a
|
||||||
|
// flaky RPC that keeps answering in between must not accumulate its
|
||||||
|
// way to a false "network unreachable".
|
||||||
|
//
|
||||||
|
// Polls 1-5 (t=10s..50s) alternate reject / null, so three fail and
|
||||||
|
// the last answer resets the count at poll 4. From poll 6 on every
|
||||||
|
// lookup fails. Six in a row is then poll 10, at t=100s. A counter
|
||||||
|
// that never reset would have reached six at poll 8, t=80s, so the
|
||||||
|
// window between those two is what this test occupies.
|
||||||
|
mockReceiptLookup.mockImplementation(() => {
|
||||||
|
const n = mockReceiptLookup.mock.calls.length;
|
||||||
|
if (n <= 5 && n % 2 === 0) return Promise.resolve(null);
|
||||||
|
return Promise.reject(new Error("flaky"));
|
||||||
|
});
|
||||||
|
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
|
||||||
|
// t=90s: eight failures in total, five of them in a row. A
|
||||||
|
// cumulative counter has long since fired; a consecutive one has not.
|
||||||
|
await jest.advanceTimersByTimeAsync(90000);
|
||||||
|
expect(state.currentView).toBe("wait-tx");
|
||||||
|
expect(visible("wait-tx")).toBe(true);
|
||||||
|
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// t=100s: the sixth in a row.
|
||||||
|
await jest.advanceTimersByTimeAsync(10000);
|
||||||
|
expect(state.currentView).toBe("error-tx");
|
||||||
|
expect(state.viewData.message).toMatch(/could not be reached/i);
|
||||||
|
// No lookup ever answered "no receipt" past the deadline, so this
|
||||||
|
// is not the timeout and must not be reported as one.
|
||||||
|
expect(state.viewData.message).not.toMatch(/not confirmed within/);
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a resumed wait against a dead RPC also terminates", async () => {
|
||||||
|
// The reopen path is the one that made this unbounded: the wait is
|
||||||
|
// persisted, so without a bound every popup open resumes it forever.
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
const persisted = JSON.parse(JSON.stringify(state.viewData));
|
||||||
|
txStatus.endWait();
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(3600000);
|
||||||
|
mockReceiptLookup.mockReset();
|
||||||
|
mockReceiptLookup.mockRejectedValue(new Error("rpc unavailable"));
|
||||||
|
|
||||||
|
state.viewData = persisted;
|
||||||
|
expect(txStatus.restoreWait()).toBe(true);
|
||||||
|
await jest.advanceTimersByTimeAsync(60000);
|
||||||
|
|
||||||
|
expect(state.currentView).toBe("error-tx");
|
||||||
|
expect(state.viewData.message).toMatch(/could not be reached/i);
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
expect(state.viewData.pendingWait).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wait-tx is a view the popup may reopen onto", () => {
|
||||||
|
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
|
||||||
|
// refuses any view not in the set, so dropping "wait-tx" from it kills
|
||||||
|
// the resume silently — the tests above call restoreWait() directly and
|
||||||
|
// would all still pass. This pins the membership. Mirrors the exclusion
|
||||||
|
// assertions in tests/showPhrase.test.js.
|
||||||
|
test("wait-tx is restorable", () => {
|
||||||
|
expect(RESTORABLE_VIEWS.has("wait-tx")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user