1 Commits
Author SHA1 Message Date
sneak f76785f4dd chore: draw the toolbar icons in-tree with make icons (closes #378)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
script/lib/icons.js draws the mark from geometry read back out of the
committed PNGs, since the coordinates were never recorded, and writes each
PNG with node's own zlib. `make icons` runs it and leaves alone a file that
already holds the drawn image.

tests/icons.test.js requires each committed file to hold exactly that
image: the same IHDR and every pixel. The compressed bytes are not
compared, because node's bundled zlib does not compress as the stock zlib
that made the committed files did; the decision is recorded on the issue.

build.js copies the manifests from MANIFEST_SOURCES instead of naming the
two paths a second time.

Model: opus-5-5
2026-10-07 03:59:57 +00:00
14 changed files with 209 additions and 633 deletions
+24 -37
View File
@@ -603,6 +603,14 @@ without an `await`. Demonstrated, not assumed: a `throw` placed past the first
and on Chrome (`pageerror`), with the rest of the run unaffected because the
approval view had already rendered.
One error is still tolerated rather than fatal, listed in `ALLOWED_ERRORS` in
`tests/e2e/firefox/run.js` and printed on every occurrence so the concession
stays visible in the run output: Firefox reporting an extension promise that
settled after its page unloaded, from anywhere in the popup. Its cause was the
site-approval popup's unawaited `sendMessage` before `window.close()`, which
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275) removed; Firefox runs
since have not printed it, and
[#487](https://git.eeqj.de/sneak/AutistMask/issues/487) removes the entry.
Errors are read from the privileged `nsIConsoleService` in Marionette's chrome
context and filtered to non-warning entries whose `sourceName` is the extension
origin. That mechanism is not a stylistic choice. WebDriver BiDi's
@@ -1263,21 +1271,14 @@ path rather than on the home screen. Read the claim narrowly, as that file
states it: what those boots prove is no structural dereference on the code paths
a WHOLLY-CORRUPTED PROFILE takes, which is not every path a stored record takes.
Not driven: any pairing of values the four slots do not produce, a view only
forward navigation opens, anything behind a click or a timer, and, of what a
healthy profile reaches, anything beyond its boot onto each view the popup can
reopen onto. The fields the router does not read share a slot on each boot, so
one of them truthy while another is falsy is reached only where the falsy slot
pairs a field that cannot be falsy with one that is. Within that boundary the
verdict is unconditional — if one of those boots leaves the popup unhealthy,
`make check` fails, including when it takes two corrupted fields at once,
because the verdict is the combined boot and the per-field re-boot that names a
culprit can only decorate the message. The combined boot must also land on the
view it stored, and each value driven only onto the restore path must land on
its view, or fall back to Home, as its row declares; a field the router reads
can legitimately change which view renders, so its own sweep is held to health
alone. `make check` also fails on a field that gains a floor while its row still
claims it has none, and on a field added to `PERSISTED_FIELDS` with no row at
all. The per-field justification that used to live in the header of
forward navigation opens, anything behind a click, and everything a healthy
profile reaches. Within that boundary the verdict is unconditional — if one of
those boots leaves the popup unhealthy or off the view it stored, `make check`
fails, including when it takes two corrupted fields at once, because the verdict
is the combined boot and the per-field re-boot that names a culprit can only
decorate the message. So does a field that gains a floor while its row still
claims it has none, and so does a field added to `PERSISTED_FIELDS` with no row
at all. The per-field justification that used to live in the header of
`src/shared/stateSchema.js` shipped a false claim in three consecutive changes,
each caught only by a reviewer re-deriving thirty fields by hand.
@@ -1451,17 +1452,14 @@ view would leave a wallet one click from deletion.
without it, the lost-password route on DeleteWallet is the first they
would hear of it. The hint line reserves its height, so switching tabs
cannot move the password fields under the pointer.
- "Import" button, with the error line beside it so that it adds no height
to a screen whose button already starts near the bottom of the popup
- "Import" button
- **Transitions**:
- "Import" with a valid entry and a matching password of at least 12
characters → creates the wallet, clears the navigation stack, and →
**Home**. The phrase and xprv modes then scan for further used addresses
and report the count as a flash message.
- "Import" with a missing, short or mismatched password → full-sentence
error on the error line, no screen change
- "Import" with an invalid entry or a duplicate wallet or address → flash
message, no screen change
- "Import" with an invalid entry, a duplicate wallet or address, or a short
or mismatched password → flash message, no screen change
- "Back" → previous screen (Welcome, Home, or Settings)
#### AddressDetail (`address`)
@@ -1500,8 +1498,8 @@ view would leave a wallet one click from deletion.
copy)
- Warning that anyone holding the private key can transfer all funds from
the address
- Password input, error line and "Reveal" button, shown until the key is
revealed
- Error line
- Password input and "Reveal" button, shown until the key is revealed
- The private key on a highlighted background, tap to copy, shown only after
the password has been accepted
- **Transitions**:
@@ -1520,14 +1518,6 @@ view would leave a wallet one click from deletion.
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.
- **Clipboard**: tapping the key copies it to the clipboard, and the wallet
never clears the clipboard afterwards; leaving the screen wipes the key from
the page only. The clipboard is the user's, not the wallet's. Clearing it
would go against what the user expects, and by then they may have copied
something else vital that the clear would destroy. The user knows the key is
secret from the warning above the password input, which says that anyone with
it can access and transfer all funds from the address, and knows it is on the
clipboard because they copied it. From then on it is theirs to manage.
#### AddressToken (`address-token`)
@@ -1839,8 +1829,8 @@ view would leave a wallet one click from deletion.
- Wallet name
- Warning box stating that anyone holding these words can take everything in
the wallet, from any device, without the password
- Password input, error line and "Reveal" button, shown until the password
is accepted
- Error line
- Password input + "Reveal" button, shown until the password is accepted
- The recovery phrase itself, in full and click-to-copy, shown only after a
correct password and in place of the password prompt
- **Transitions**:
@@ -1858,9 +1848,6 @@ view would leave a wallet one click from deletion.
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 phrase.
- **Clipboard**: tapping the phrase copies it, and the wallet never clears the
clipboard afterwards, for the reasons given under ExportPrivKey; here the
warning box above the password input is what tells the user it is secret.
#### DeleteWallet (`delete-wallet-confirm`)
@@ -1869,8 +1856,8 @@ view would leave a wallet one click from deletion.
- "Back" button, "Delete Wallet" heading
- Warning naming the wallet and stating that deletion is permanent and any
funds are unrecoverable without the recovery phrase
- Password input
- Error line
- Password input
- "Confirm Delete" button
- An underlined "I have lost my password" control
- **Transitions**:
+14 -89
View File
@@ -23,106 +23,28 @@
pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The
milestone is in flight on `next`; its `next` -> `main` PR is
[#388](https://git.eeqj.de/sneak/AutistMask/pulls/388). `make build` produces
`dist/chrome/` and `dist/firefox/` with `DEBUG` compiled off, and checks them
against the build's own receipt to hold exactly the regular files and symlinks
that build emitted.
[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified
green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces
`dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to
hold exactly the regular files and symlinks that build emitted, with `DEBUG`
compiled off.
The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
authoritative; this file does not duplicate it. Full policy file set present.
Real-browser end-to-end suites (`make test-e2e` for Chrome,
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which runs the
tests, static analysis and the formatting check, and `.gitea/workflows/e2e.yml`
runs both of them on every push.
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does
static analysis as well as formatting, and `.gitea/workflows/e2e.yml` runs both
of them on every push.
# Next Step
Cut 1.0.0 once the
[1.0.0 milestone](https://git.eeqj.de/sneak/AutistMask/milestone/6) is empty,
then continue tagging as milestones land.
Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
input validation) before any 1.0rc tag. Individual filed issues are parts of it,
but the review is broader than any of them.
# Completed Steps
- 2026-10-07: Two holes in what `tests/persistedFieldContract.test.js` checks
are closed ([#379](https://git.eeqj.de/sneak/AutistMask/issues/379)). The
check that every swept field is driven both truthy and falsy counts only
`hostile` and `falsy` values, which the sweep drives onto every restorable
view, and no longer a `hostileRestore` value, which reaches only the views its
entry names; the stale index 5 moves into `hostile` for `selectedWallet` and
`selectedAddress`, as the one value of either still truthy after the floor.
Each `hostileRestore` entry declares whether its boot lands on its view or
falls back to Home, and is held to it. The limits that remain, fields that
share a slot on one boot and anything no stored record reaches by itself, are
restated as built in the file's header and the README, which now also say
which boots are held to where the popup lands and that a healthy profile is
booted onto every restorable view.
- 2026-10-07: Every password error in the popup is shown the same way
([#493](https://git.eeqj.de/sneak/AutistMask/issues/493)): with `showError()`
and `hideError()` in a fixed-height error line below the password field, as
the send confirmation and approval screens already did. The add wallet screen
showed a missing, short or mismatched password in the flash line, and the
private key export, recovery phrase and delete wallet screens each had a line
of their own above the field. On the add wallet screen the line sits beside
the Import button, so the button stays where it was at 360x600. Each line
clears when the screen is shown again and when the password is tried again.
The add wallet screen's other messages, such as an invalid recovery phrase, a
duplicate wallet and the address scan, stay in the flash line.
`tests/passwordErrorLines.test.js` drives all four screens in the popup.
- 2026-10-07: Pre-1.0 security review of the extension
([#383](https://git.eeqj.de/sneak/AutistMask/issues/383)), reading the tree at
`99292b9` for key handling, the DEBUG mode policy, and what the background
accepts from pages, the configured RPC endpoint and the explorer, with what
the approval screens show from it; the site permission model and storage were
read as well. Its summary on that issue lists ten findings, each filed as its
own issue, and all ten are fixed on `next`; one,
[#399](https://git.eeqj.de/sneak/AutistMask/issues/399), put funds at risk.
Three decisions it raised are still open with the owner: the Argon2id cost for
the vault key ([#401](https://git.eeqj.de/sneak/AutistMask/issues/401)), a
connected site switching the network with no prompt
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)), and `eth_sign`
signing as a personal message
([#409](https://git.eeqj.de/sneak/AutistMask/issues/409)). Not covered: the
end-to-end suites were not run, the bundled phishing blocklist and token list
were not checked entry by entry, `ethers` and `libsodium-wrappers-sumo` were
taken as audited, and nothing was tried against a real network with real funds
([#385](https://git.eeqj.de/sneak/AutistMask/issues/385)). The planned
independent second check of each finding did not run; the findings rest on the
reviewer's own reading of the code.
- 2026-10-07: Stale branches pruned from `origin`
([#167](https://git.eeqj.de/sneak/AutistMask/issues/167)). The issue
classifies each branch it lists, with the evidence. The eighteen still on
`origin` that it classifies as landed, or superseded by merged pull requests,
were deleted. `feat/message-signing` and `fix/59-transaction-view-ui-policies`
had been deleted on 2026-09-09; both landed and stay deleted. Four are kept
because their work is not in `next`: `fix/consistent-error-display`,
`fix/87-consistent-error-display` and `fix/87-consistent-error-display-v2`,
the change for [#87](https://git.eeqj.de/sneak/AutistMask/issues/87) that
never landed, as reference for
[#493](https://git.eeqj.de/sneak/AutistMask/issues/493); and
`chore/token-list-enrichment`, deleted on 2026-09-09 and re-created at
`f7a2437`, whose `scripts/` tooling waits on
[#495](https://git.eeqj.de/sneak/AutistMask/issues/495).
- 2026-10-07: The README says that the wallet never clears the clipboard after
the private key or the recovery phrase is copied, and why
([#492](https://git.eeqj.de/sneak/AutistMask/issues/492)): the clipboard is
the user's, clearing it would go against what they expect, and it could
destroy something else they copied since. It is under ExportPrivKey, with a
line under ShowRecoveryPhrase, and names the warning each password screen
actually shows, which says nothing about the clipboard.
- 2026-10-07: The Firefox end-to-end suite no longer tolerates any uncaught
extension error ([#487](https://git.eeqj.de/sneak/AutistMask/issues/487)). Its
one entry, Firefox reporting a popup promise that settled after the page
unloaded, had lost its cause with
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275); the entry and the
code that printed tolerated errors are gone from `tests/e2e/firefox/run.js`,
and so is the README paragraph that described it.
- 2026-10-07: The toolbar icons in `icons/` can be redrawn in the tree
([#378](https://git.eeqj.de/sneak/AutistMask/issues/378)): `make icons` runs
`script/lib/icons.js`, which draws the mark and writes each PNG with node's
@@ -1934,3 +1856,6 @@ then continue tagging as milestones land.
Only work that has no issue of its own belongs here; everything else is on the
tracker.
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
land.
+20 -30
View File
@@ -207,22 +207,12 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<!-- The error line sits beside Import, not above it: at
360x600 the button already starts near the bottom of
the popup, and a line of its own would push it below
the fold. The longest error fits on one line here. -->
<div class="flex items-center gap-2">
<button
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
<div
id="add-wallet-password-error"
class="text-xs min-h-[1.25rem] invisible"
></div>
</div>
<button
id="btn-add-wallet-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Import
</button>
</div>
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
@@ -406,6 +396,10 @@
Warning: anyone with this private key can access and
transfer all funds from this address. Never share it.
</p>
<div
id="export-privkey-flash"
class="text-xs mb-2 min-h-[1.25rem] invisible"
></div>
<div id="export-privkey-password-section" class="mb-2">
<label class="block mb-1">Password</label>
<input
@@ -414,13 +408,9 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue"
/>
<div
id="export-privkey-password-error"
class="text-xs mt-2 mb-2 min-h-[1.25rem] invisible"
></div>
<button
id="btn-export-privkey-confirm"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
>
Reveal
</button>
@@ -1126,6 +1116,10 @@
<strong id="delete-wallet-name"></strong> is permanent. Any
funds will be unrecoverable without your recovery phrase.
</p>
<div
id="delete-wallet-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
></div>
<div class="mb-2">
<label class="block mb-1">Password</label>
<input
@@ -1135,10 +1129,6 @@
placeholder="Enter your password to confirm"
/>
</div>
<div
id="delete-wallet-password-error"
class="text-xs mb-2 min-h-[1.25rem] invisible"
></div>
<button
id="btn-delete-wallet-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -1283,6 +1273,10 @@
this wallet, from any device, without your password. Never
type them into a website and never show them to anyone.
</div>
<div
id="show-phrase-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
></div>
<div id="show-phrase-password-section" class="mb-2">
<label class="block mb-1">Password</label>
<input
@@ -1291,13 +1285,9 @@
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Enter your password to continue"
/>
<div
id="show-phrase-password-error"
class="text-xs mt-2 mb-2 min-h-[1.25rem] invisible"
></div>
<button
id="btn-show-phrase-reveal"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
>
Reveal
</button>
+4 -12
View File
@@ -2,8 +2,6 @@ const {
$,
showView,
showFlash,
showError,
hideError,
goBack,
clearViewStack,
onViewLeave,
@@ -100,7 +98,6 @@ function clear() {
$("add-wallet-password").value = "";
$("add-wallet-password-confirm").value = "";
$("add-wallet-phrase-warning").style.visibility = "hidden";
hideError("add-wallet-password-error");
}
// Each wallet has its own password (its own encryptedSecret), so adding a
@@ -128,18 +125,15 @@ function validatePassword() {
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showError("add-wallet-password-error", "Please choose a password.");
showFlash("Please choose a password.");
return null;
}
if (pw.length < 12) {
showError(
"add-wallet-password-error",
"Password must be at least 12 characters.",
);
showFlash("Password must be at least 12 characters.");
return null;
}
if (pw !== pw2) {
showError("add-wallet-password-error", "Passwords do not match.");
showFlash("Passwords do not match.");
return null;
}
return pw;
@@ -348,10 +342,8 @@ function init(ctx) {
$("add-wallet-phrase-warning").style.visibility = "visible";
});
// Import / confirm. Each press starts with no password error on screen:
// validatePassword() puts it back if the password is still wrong.
// Import / confirm
$("btn-add-wallet-confirm").addEventListener("click", async () => {
hideError("add-wallet-password-error");
if (currentMode === "mnemonic") {
await importMnemonic(ctx);
} else if (currentMode === "privkey") {
+11 -16
View File
@@ -2,8 +2,6 @@ const {
$,
showView,
showFlash,
showError,
hideError,
goBack,
clearViewStack,
onViewLeave,
@@ -57,7 +55,8 @@ function confirmKey(name) {
function clear() {
deleteWalletIndex = null;
$("delete-wallet-password").value = "";
hideError("delete-wallet-password-error");
$("delete-wallet-flash").textContent = "";
$("delete-wallet-flash").style.visibility = "hidden";
}
// The lost-password screen holds no secret — a wallet name is not one —
@@ -233,22 +232,19 @@ function init(_ctx) {
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value;
if (!pw) {
showError(
"delete-wallet-password-error",
"Please enter your password.",
);
$("delete-wallet-flash").textContent =
"Please enter your password.";
$("delete-wallet-flash").style.visibility = "visible";
return;
}
if (deleteWalletIndex === null) {
showError(
"delete-wallet-password-error",
"No wallet selected for deletion.",
);
$("delete-wallet-flash").textContent =
"No wallet selected for deletion.";
$("delete-wallet-flash").style.visibility = "visible";
return;
}
hideError("delete-wallet-password-error");
const btn = $("btn-delete-wallet-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
@@ -260,10 +256,9 @@ function init(_ctx) {
try {
await decryptWithPassword(wallet.encryptedSecret, pw);
} catch {
showError(
"delete-wallet-password-error",
"That password is incorrect. Please try again.",
);
$("delete-wallet-flash").textContent =
"That password is incorrect. Please try again.";
$("delete-wallet-flash").style.visibility = "visible";
btn.disabled = false;
btn.classList.remove("text-muted");
return;
+12 -13
View File
@@ -20,8 +20,6 @@ const {
$,
showView,
showFlash,
showError,
hideError,
flashCopyFeedback,
goBack,
onViewLeave,
@@ -58,6 +56,11 @@ function isCurrentReveal(generation) {
);
}
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() {
@@ -68,7 +71,8 @@ function clear() {
$("export-privkey-password").value = "";
$("export-privkey-result").classList.add("hidden");
$("export-privkey-password-section").classList.remove("hidden");
hideError("export-privkey-password-error");
$("export-privkey-flash").textContent = "";
$("export-privkey-flash").style.visibility = "hidden";
}
function show(walletIdx, addrIdx) {
@@ -108,19 +112,15 @@ function show(walletIdx, addrIdx) {
async function reveal() {
const password = $("export-privkey-password").value;
if (!password) {
showError(
"export-privkey-password-error",
"Please enter your password.",
);
fail("Please enter your password.");
return;
}
if (walletIndex === null) {
showError("export-privkey-password-error", "No address is selected.");
fail("No address is selected.");
return;
}
const wallet = state.wallets[walletIndex];
hideError("export-privkey-password-error");
const btn = $("btn-export-privkey-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
@@ -140,12 +140,11 @@ async function reveal() {
$("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;
showError(
"export-privkey-password-error",
"That password is incorrect. Please try again.",
);
fail("That password is incorrect. Please try again.");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
+13 -14
View File
@@ -21,8 +21,6 @@ const {
$,
showView,
showFlash,
showError,
hideError,
flashCopyFeedback,
goBack,
onViewLeave,
@@ -54,6 +52,11 @@ function isCurrentReveal(generation) {
);
}
function fail(message) {
$("show-phrase-flash").textContent = message;
$("show-phrase-flash").style.visibility = "visible";
}
// Wipe every trace of the phrase and drop the wallet selection. Safe to
// call when nothing was ever revealed, and safe to call twice.
function clear() {
@@ -63,7 +66,8 @@ function clear() {
$("show-phrase-password").value = "";
$("show-phrase-result").classList.add("hidden");
$("show-phrase-password-section").classList.remove("hidden");
hideError("show-phrase-password-error");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
}
function show(walletIdx) {
@@ -86,23 +90,19 @@ function show(walletIdx) {
async function reveal() {
const password = $("show-phrase-password").value;
if (!password) {
showError("show-phrase-password-error", "Please enter your password.");
fail("Please enter your password.");
return;
}
if (walletIndex === null) {
showError("show-phrase-password-error", "No wallet is selected.");
fail("No wallet is selected.");
return;
}
const wallet = state.wallets[walletIndex];
if (!walletHasRecoveryPhrase(wallet)) {
showError(
"show-phrase-password-error",
"This wallet does not have a recovery phrase.",
);
fail("This wallet does not have a recovery phrase.");
return;
}
hideError("show-phrase-password-error");
const btn = $("btn-show-phrase-reveal");
btn.disabled = true;
btn.classList.add("text-muted");
@@ -120,14 +120,13 @@ async function reveal() {
$("show-phrase-password-section").classList.add("hidden");
$("show-phrase-value").textContent = phrase;
$("show-phrase-result").classList.remove("hidden");
$("show-phrase-flash").textContent = "";
$("show-phrase-flash").style.visibility = "hidden";
} catch {
if (!isCurrentReveal(generation)) return;
// Deliberately not the caught error: the message is fixed so that
// nothing derived from the ciphertext or the attempt can surface.
showError(
"show-phrase-password-error",
"That password is incorrect. Please try again.",
);
fail("That password is incorrect. Please try again.");
} finally {
btn.disabled = false;
btn.classList.remove("text-muted");
-2
View File
@@ -39,8 +39,6 @@ jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
showFlash: () => {},
showError: () => {},
hideError: () => {},
goBack: () => {},
clearViewStack: () => {},
onViewLeave: () => {},
+1 -1
View File
@@ -253,7 +253,7 @@ describe("reaching the screen", () => {
const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockRejectedValue(new Error("nope"));
await click("btn-delete-wallet-confirm");
expect(node("delete-wallet-password-error").textContent).toBe(
expect(node("delete-wallet-flash").textContent).toBe(
"That password is incorrect. Please try again.",
);
});
+52 -1
View File
@@ -854,6 +854,37 @@ step(
// ------------------------------------------------------------- runner
// Uncaught extension errors that are known, tracked and deliberately
// tolerated, in the same spirit as ALLOWED_ERRORS in tests/e2e/harness.js:
// every entry names the issue that will delete it, and every occurrence is
// still printed, so tolerating one is visible in the log rather than silent.
// This is the only concession in an otherwise zero-tolerance policy.
const ALLOWED_ERRORS = [
{
// The site-connection buttons in src/popup/views/approval.js send
// their decision and call window.close() on the next line. Firefox's
// BaseContext.wrapPromise reports, through Cu.reportError, any
// extension-API promise that settles after its context unloaded —
// whether or not the caller attached a handler, so notify()'s catch
// cannot suppress it.
//
// Pre-existing, and not introduced by the promise shim: the send was
// already unawaited, and this suite is merely the first thing to
// drive that window on Firefox. It is the same teardown ordering as
// the issue below, whose fix — making the outcome independent of when
// the popup closes — removes this entry with it.
pattern: /Promise (?:resolved|rejected) after context unloaded/,
source: /\/src\/popup\/index\.js$/,
issue: "https://git.eeqj.de/sneak/AutistMask/issues/275",
},
];
function allowedFor(e) {
return ALLOWED_ERRORS.find(
(a) => a.pattern.test(e.msg) && a.source.test(e.src),
);
}
function formatError(e) {
return (
e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")"
@@ -970,6 +1001,20 @@ async function main() {
installFailure = null;
}
// Tolerated errors are set aside, never dropped: each one is
// printed with the issue that keeps it on the list, so the
// concession stays in the run output.
const tolerated = found.filter((e) => allowedFor(e));
found = found.filter((e) => !allowedFor(e));
for (const e of tolerated) {
console.log(
"# tolerated (" +
allowedFor(e).issue +
"): " +
formatError(e),
);
}
// Any uncaught error from an extension source fails the step
// that provoked it, whether or not its assertions passed.
if (!failure && found.length > 0) {
@@ -994,7 +1039,13 @@ async function main() {
// blamed on any one step, but they are still reported and they
// still fail the run.
await sleep(1000);
const trailing = await errors.take();
const trailingAll = await errors.take();
for (const e of trailingAll.filter((x) => allowedFor(x))) {
console.log(
"# tolerated (" + allowedFor(e).issue + "): " + formatError(e),
);
}
const trailing = trailingAll.filter((e) => !allowedFor(e));
console.log(
"# " +
(steps.length - failed) +
+6 -109
View File
@@ -809,7 +809,7 @@ async function secretScreenState(page, view) {
return page.evaluate(
(v) => ({
value: document.getElementById(v + "-value").textContent,
error: document.getElementById(v + "-password-error").textContent,
error: document.getElementById(v + "-flash").textContent,
html: document.getElementById("view-" + v).innerHTML,
resultHidden: document
.getElementById(v + "-result")
@@ -906,8 +906,7 @@ test("a wrong password reveals nothing (#161)", async (env) => {
await env.page.click("#btn-show-phrase-reveal");
await env.page.waitForFunction(
() =>
document.getElementById("show-phrase-password-error").textContent
.length > 0,
document.getElementById("show-phrase-flash").textContent.length > 0,
null,
{ timeout: 60000 },
);
@@ -1994,14 +1993,13 @@ test("an over-long flash message keeps to one line (#252)", async (env) => {
// Every screen that asks for a password reserves room for one line of error.
// The two on the dApp approval screens also have a border and padding, which
// that reserved height has to cover too. The add wallet screen's line sits
// beside its button rather than above it, and has its own test below.
// that reserved height has to cover too.
const PASSWORD_ERROR_CONTAINERS = [
"approve-tx-error",
"approve-sign-error",
"export-privkey-password-error",
"show-phrase-password-error",
"delete-wallet-password-error",
"export-privkey-flash",
"show-phrase-flash",
"delete-wallet-flash",
"confirm-tx-password-error",
];
@@ -2066,107 +2064,6 @@ test("a password error moves nothing on any screen (#297)", async (env) => {
}
});
// ------------------------------------------ add wallet Import button (#493)
// At 360x600 the add wallet screen's Import button already starts near the
// bottom of the popup, so its password error line sits beside the button
// rather than above it. Measures the button and the line empty and again
// filled with the longest error addWallet.js puts there. Runs in the page.
function measureImportButton() {
const button = document.getElementById("btn-add-wallet-confirm");
const line = document.getElementById("add-wallet-password-error");
const measure = () => {
const b = button.getBoundingClientRect();
const l = line.getBoundingClientRect();
return {
buttonTop: b.top + window.scrollY,
buttonBottom: b.bottom + window.scrollY,
lineTop: l.top + window.scrollY,
lineBottom: l.bottom + window.scrollY,
};
};
const empty = measure();
line.textContent = "Password must be at least 12 characters.";
line.style.visibility = "visible";
const filled = measure();
line.textContent = "";
line.style.visibility = "hidden";
return { empty, filled };
}
test("the add wallet password error leaves Import where it was (#493)", async (env) => {
const page = await openPopup(env.ctx, env.popupUrl);
try {
await page.setViewportSize(POPUP_VIEWPORT);
// Brought up by toggling classes, as in the test above. The note
// addWallet.js shows once a wallet exists is the only part of the
// screen that differs between the first wallet and a later one.
await page.evaluate(() => {
const screen = document.getElementById("view-add-wallet");
for (const view of document.querySelectorAll(".view")) {
view.classList.toggle("hidden", view !== screen);
}
});
for (const walletExists of [false, true]) {
await page.evaluate(
(shown) =>
document
.getElementById("add-wallet-separate-password-note")
.classList.toggle("hidden", !shown),
walletExists,
);
for (const tab of ["tab-mnemonic", "tab-privkey", "tab-xprv"]) {
await page.click("#" + tab);
const { empty, filled } =
await page.evaluate(measureImportButton);
const where =
"#" +
tab +
(walletExists
? " with a wallet already added"
: " for the first wallet");
// Printed pass or fail, as the dust threshold test does.
console.log(
"# add wallet Import top, " +
where +
": " +
empty.buttonTop +
"px",
);
for (const m of [empty, filled]) {
assert(
m.lineTop >= m.buttonTop &&
m.lineBottom <= m.buttonBottom,
"the error line on " +
where +
" does not fit beside Import, so it adds height: " +
JSON.stringify(m),
);
}
assert(
filled.buttonTop === empty.buttonTop,
"Import moved " +
(filled.buttonTop - empty.buttonTop) +
"px when the error appeared on " +
where,
);
if (!walletExists) {
assert(
empty.buttonTop < POPUP_VIEWPORT.height,
"Import starts at " +
empty.buttonTop +
"px on " +
where +
", below the fold",
);
}
}
}
} finally {
await page.close();
}
});
// --------------------------------------------- confirmation screen (#238)
//
// The screen that decides what gets signed. The arithmetic underneath it
+4 -6
View File
@@ -207,7 +207,7 @@ describe("a decrypt still running when the screen is left", () => {
});
// Same hole on the failure path: a wrong-password error written after
// the wipe would restore the error line on a screen the user has left.
// 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);
@@ -217,10 +217,8 @@ describe("a decrypt still running when the screen is left", () => {
reveal.reject(new Error("decryption failed"));
await reveal.pending;
expect(node("export-privkey-password-error").textContent).toBe("");
expect(node("export-privkey-password-error").style.visibility).toBe(
"hidden",
);
expect(node("export-privkey-flash").textContent).toBe("");
expect(node("export-privkey-flash").style.visibility).toBe("hidden");
});
});
@@ -262,7 +260,7 @@ describe("a reveal that is not interrupted", () => {
await reveal.pending;
expect(node("export-privkey-value").textContent).toBe("");
expect(node("export-privkey-password-error").textContent).toBe(
expect(node("export-privkey-flash").textContent).toBe(
"That password is incorrect. Please try again.",
);
});
-159
View File
@@ -1,159 +0,0 @@
// Every screen that asks for a password shows a password error the same way:
// through showError() and hideError() in src/popup/views/helpers.js, in a
// fixed-height error line below the password field, and never in the flash
// line at the top of the popup
// (https://git.eeqj.de/sneak/AutistMask/issues/493). These boot the real popup
// over src/popup/index.html, so each error line has to exist in the markup,
// and check that the error appears in it and clears again.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
encryptWithPassword: jest.fn(),
}));
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const PASSWORD = "correct horse battery staple";
const WRONG_PASSWORD = "That password is incorrect. Please try again.";
afterEach(() => {
cleanupPopup();
});
// The error line as the user sees it.
function errorLine(page, id) {
return {
inMarkup: page.document.authoredIds.has(id),
text: page.text(id),
visibility: page.node(id).style.visibility,
};
}
function shown(text) {
return { inMarkup: true, text, visibility: "visible" };
}
const cleared = { inMarkup: true, text: "", visibility: "hidden" };
describe("the add wallet screen", () => {
const ERROR = "add-wallet-password-error";
// First run: Welcome, "Add wallet", then the die for a valid phrase.
async function openAddWallet() {
const page = await bootPopup(undefined);
await page.click("btn-welcome-add");
await page.click("btn-generate-phrase");
return page;
}
function setPasswords(page, password, confirm) {
page.node("add-wallet-password").value = password;
page.node("add-wallet-password-confirm").value = confirm;
}
test("shows a password problem below the password fields, and clears it on the next press", async () => {
const page = await openAddWallet();
setPasswords(page, "short", "short");
await page.click("btn-add-wallet-confirm");
expect(errorLine(page, ERROR)).toEqual(
shown("Password must be at least 12 characters."),
);
expect(page.text("flash-msg")).toBe("");
// The password is fixed and the phrase emptied: the password error
// goes, and the phrase problem is still reported in the flash line.
setPasswords(page, PASSWORD, PASSWORD);
page.node("wallet-mnemonic").value = "";
await page.click("btn-add-wallet-confirm");
expect(errorLine(page, ERROR)).toEqual(cleared);
expect(page.text("flash-msg")).toBe(
"Enter a recovery phrase, or press the die.",
);
// Leaving clears the flash line and stops its timer, which would
// otherwise fire after this page is gone.
await page.click("btn-add-wallet-back");
});
test("clears the error when the screen is shown again", async () => {
const page = await openAddWallet();
setPasswords(page, PASSWORD, PASSWORD + " typo");
await page.click("btn-add-wallet-confirm");
expect(errorLine(page, ERROR)).toEqual(
shown("Passwords do not match."),
);
await page.click("btn-add-wallet-back");
await page.click("btn-welcome-add");
expect(errorLine(page, ERROR)).toEqual(cleared);
});
});
describe.each([
{
screen: "the private key export screen",
open: () => require("../src/popup/views/exportPrivkey").show(0, 0),
field: "export-privkey-password",
button: "btn-export-privkey-confirm",
error: "export-privkey-password-error",
},
{
screen: "the recovery phrase screen",
open: () => require("../src/popup/views/showPhrase").show(0),
field: "show-phrase-password",
button: "btn-show-phrase-reveal",
error: "show-phrase-password-error",
},
{
screen: "the delete wallet screen",
open: () => require("../src/popup/views/deleteWallet").show(0),
field: "delete-wallet-password",
button: "btn-delete-wallet-confirm",
error: "delete-wallet-password-error",
},
])("$screen", ({ open, field, button, error }) => {
// Opens the screen and enters a password the vault rejects.
async function failedAttempt() {
const page = await bootPopup(unversionedValidProfile());
open();
const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockRejectedValue(new Error("wrong password"));
page.node(field).value = "not the password";
await page.click(button);
return { page, decryptWithPassword };
}
test("shows a wrong password below the password field", async () => {
const { page } = await failedAttempt();
expect(errorLine(page, error)).toEqual(shown(WRONG_PASSWORD));
});
test("clears the error while the next password is checked", async () => {
const { page, decryptWithPassword } = await failedAttempt();
let rejectDecrypt;
decryptWithPassword.mockReturnValue(
new Promise((resolve, reject) => {
rejectDecrypt = reject;
}),
);
page.node(field).value = "another guess";
const pressed = page.click(button);
await page.settle();
expect(errorLine(page, error)).toEqual(cleared);
rejectDecrypt(new Error("wrong password"));
await pressed;
expect(errorLine(page, error)).toEqual(shown(WRONG_PASSWORD));
});
test("clears the error when the screen is shown again", async () => {
const { page } = await failedAttempt();
open();
expect(errorLine(page, error)).toEqual(cleared);
});
});
+48 -144
View File
@@ -49,37 +49,22 @@
// every path a stored record takes, and the difference is the whole of what
// this file does not cover:
//
// - Only the values in the table, in the SLOT arrangement below. On the
// restore path the twelve fields the router does not read are corrupted
// together, every field on the same slot, so a view gets four value
// combinations of them, not their product. A branch entered only when one
// of them is truthy and another falsy is reached only where the falsy
// slot happens to pair a field that cannot be falsy with one that is.
// Each field the router reads is corrupted alone, over an otherwise
// well-formed record.
// - Only what a stored record reaches by ITSELF, as the boot renders it. A
// view only forward navigation opens, anything behind a click, and
// anything behind a timer (bootPopup() records every interval, and this
// file never runs one) is not driven.
// - Of the paths a HEALTHY profile takes, only its boot onto each
// restorable view ("the base profile the sweep corrupts" below). The rest
// of the popup is not covered: this file is a floor under one defect
// class, not a proof about the renderers.
// - Only the values in the table, in the SLOT arrangement below: four value
// combinations per view, not the product of twelve fields. A dereference
// reached only under a pairing no slot produces is not driven at all.
// - Only what a stored record reaches by ITSELF. A view only forward
// navigation opens, and anything behind a click, is not driven.
// - Nothing about the paths a HEALTHY profile takes, which is most of the
// popup. This file is a floor under one defect class, not a proof about
// the renderers.
//
// Within that boundary it is unconditional: if a boot that corrupts a field
// leaves the popup unhealthy, this file goes red — including when it takes
// two corrupted fields at once, because the verdict is the combined boot
// itself and the per-field re-boot below can only decorate the message. That
// last part is the one thing an earlier version got wrong: it asserted on the
// per-field list, so an observed dead popup that no single field reproduced
// was reported green.
//
// Where the popup lands is held for some of those boots and not others. The
// combined boot must land on the view it stored, and each `hostileRestore`
// value must land on its view, or fall back to Home, as its entry declares.
// The boots in "a hostile routing value restoring onto" are held to health
// alone, because a value in a field the router reads legitimately changes
// which view renders; so are the boots onto Home, which store no view.
// Within that boundary it is unconditional: if one of these boots leaves the
// popup unhealthy or off the view it stored, this file goes red — including
// when it takes two corrupted fields at once, because the verdict is the
// combined boot itself and the per-field re-boot below can only decorate the
// message. That last part is the one thing an earlier version got wrong: it
// asserted on the per-field list, so an observed dead popup that no single
// field reproduced was reported green.
//
// Booting every field separately at every value would be several hundred boots
// and most of the suite's budget; this is forty-four. Widening it further is
@@ -143,11 +128,7 @@ const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
// list short and pointed. `floorOnly` is extra values checked against the
// floor alone, which is pure and free. `hostileRestore` is extra values driven
// through the restore path only, for a value that means nothing until a
// particular branch's gate has let it past. Each of its entries names the
// `views` it is driven onto and declares whether the boot lands on them
// (`restored: true`) or falls back to Home (`restored: false`), so a value
// written for one renderer cannot stop reaching it unnoticed. A value driven
// onto every restorable view is written with everyRestorableView() below.
// particular branch's gate has let it past.
//
// `falsy` is the other POLARITY of a swept field, driven for the same reason.
// It is not a value src/ never writes — for three of these fields it is the
@@ -158,23 +139,6 @@ const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
// falsy value stored under that field comes back TRUTHY from the floor, so no
// `!state.x` branch is reachable from a stored record at all.
// The `hostileRestore` entries for a value driven onto every restorable view:
// it falls back to Home on the views listed in `fallsBackOn` and lands on every
// other one, so a view added to RESTORABLE_VIEWS is driven, and expected to
// land, without editing the row.
function everyRestorableView(value, fallsBackOn) {
return [
{ value, views: fallsBackOn, restored: false },
{
value,
views: [...RESTORABLE_VIEWS].filter(
(view) => !fallsBackOn.includes(view),
),
restored: true,
},
];
}
const CONTRACT = [
{
field: "wallets",
@@ -316,38 +280,28 @@ const CONTRACT = [
kind: KIND.SCALAR,
// The prototype members are the whole point: `wallets["map"]` is
// TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and
// `.addresses[…]` throws. A stale INTEGER is the safe case and has to
// stay so. 5 is one, out of range for the one wallet in the profile,
// and it is also this field's truthy polarity: every other value here
// comes back from the floor as null.
hostile: ["map", "__proto__", { a: 1 }, 5],
// `.addresses[…]` throws. A stale INTEGER is the safe case.
hostile: ["map", "__proto__", { a: 1 }],
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
holds: isIndexOrNull,
// SCALAR, and swept anyway: the restore path is precisely why this
// field gained a floor, so the sweep is the regression guard on it.
alsoSweep: true,
routes: true,
// Comes back from the floor as null, which hasValidAddress() reads as
// nothing selected: the popup falls back to Home on the five views
// that need an address, and lands on every other one.
hostileRestore: everyRestorableView("length", [
"address",
"address-token",
"receive",
"transaction",
"confirm-tx",
]),
// A stale INTEGER index, which reaches the restore path by a different
// route from the prototype members above — falsy or out of range
// rather than truthy — and has to keep being the safe case.
hostileRestore: [{ value: "length" }, { value: 5 }],
},
{
field: "selectedAddress",
kind: KIND.SCALAR,
// 5 for the same reason as in selectedWallet: a stale index, and the
// one value here still truthy after the floor.
hostile: ["map", "__proto__", { a: 1 }, 5],
hostile: ["map", "__proto__", { a: 1 }],
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
holds: isIndexOrNull,
alsoSweep: true,
routes: true,
hostileRestore: [{ value: 5 }],
},
{
field: "currentView",
@@ -371,9 +325,7 @@ const CONTRACT = [
// container shapes below onto every restorable view; hostileRestore
// adds the records that PASS a branch's gate and then hand its
// renderer something it dereferences, which is where the entries are
// actually decided. The guard refuses each single-view record below,
// so each is declared to fall back to Home: one that started landing
// would be reaching the renderer it was written against.
// actually decided.
hostile: [42, "notarecord", { a: 1 }, [1, 2]],
// `structuredClone(saved.viewData || {})`: the container is never falsy
// in state whatever was stored, so no `!state.viewData` branch exists to
@@ -382,20 +334,11 @@ const CONTRACT = [
hostileRestore: [
// success-tx passes on `data.hash`, and renderSuccess() then calls
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
{
value: { hash: "0x1" },
views: ["success-tx"],
restored: false,
},
{
value: { hash: "0x1", to: 42 },
views: ["success-tx"],
restored: false,
},
{ value: { hash: "0x1" }, views: ["success-tx"] },
{ value: { hash: "0x1", to: 42 }, views: ["success-tx"] },
{
value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
views: ["success-tx"],
restored: false,
},
{
value: {
@@ -404,25 +347,12 @@ const CONTRACT = [
decoded: { details: [{ address: 42 }] },
},
views: ["success-tx"],
restored: false,
},
// error-tx passes on `data.message`, same dereference.
{
value: { message: "boom" },
views: ["error-tx"],
restored: false,
},
{
value: { message: "boom", to: 42 },
views: ["error-tx"],
restored: false,
},
{ value: { message: "boom" }, views: ["error-tx"] },
{ value: { message: "boom", to: 42 }, views: ["error-tx"] },
// transaction passes on `data.tx`.
{
value: { tx: { hash: "0x1" } },
views: ["transaction"],
restored: false,
},
{ value: { tx: { hash: "0x1" } }, views: ["transaction"] },
{
value: {
tx: {
@@ -433,14 +363,9 @@ const CONTRACT = [
},
},
views: ["transaction"],
restored: false,
},
// confirm-tx passes on `data.pendingTx`.
{
value: { pendingTx: { amount: "1" } },
views: ["confirm-tx"],
restored: false,
},
{ value: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] },
{
value: {
pendingTx: {
@@ -451,7 +376,6 @@ const CONTRACT = [
},
},
views: ["confirm-tx"],
restored: false,
},
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked
// the fields below it since it was written, and this is the
@@ -464,29 +388,20 @@ const CONTRACT = [
},
},
views: ["wait-tx"],
restored: false,
},
// A record that passes EVERY branch's gate at once, driven onto
// every restorable view: a branch a view does not read must stay
// one it does not read. The five views with a viewData branch
// refuse it; every other one renders it, and must survive the
// fields every branch left behind.
...everyRestorableView(
{
// one it does not read, and each renderer must survive the fields
// another branch left behind.
{
value: {
hash: "0x1",
message: "boom",
tx: { hash: "0x1" },
pendingTx: { amount: "1" },
pendingWait: { hash: "0x1" },
},
[
"confirm-tx",
"transaction",
"wait-tx",
"success-tx",
"error-tx",
],
),
},
],
},
{
@@ -668,11 +583,6 @@ const HEALTHY = { errors: [], blank: false };
// set is all-truthy by construction, so without a falsy slot a dereference
// behind `if (!state.x)` is never reached on the boot that corrupts x — the
// same falsy-collapse blind spot the fields below were floored for.
//
// Only `hostile` and `falsy` values count. Those go through the sweep below,
// which covers every restorable view; a `hostileRestore` entry is driven onto
// only the views it names, so a polarity it alone supplied might reach a single
// renderer.
describe("both polarities of every swept field are driven", () => {
const FALSY_STORED = [0, "", false, null];
const floored = (field, value) =>
@@ -696,9 +606,10 @@ describe("both polarities of every swept field are driven", () => {
test(`${row.field}: truthy and falsy`, () => {
// What the boots below actually drive, floored the way a renderer
// sees it — not what the row says it drives.
const driven = sweptValues(row).map((value) =>
floored(row.field, value),
);
const driven = [
...sweptValues(row),
...(row.hostileRestore || []).map((entry) => entry.value),
].map((value) => floored(row.field, value));
expect({
truthy: driven.some((value) => Boolean(value)),
@@ -954,27 +865,20 @@ describe("every field the router does not read, corrupted at once, onto", () =>
// The values that only mean something on the restore path: a viewData that
// PASSES a branch's gate and then hands its renderer something dereferenced,
// and a selectedWallet the floor turns into nothing selected. Each boot must
// throw nothing and show exactly the view its entry says it lands on: its own,
// or Home, so a value written for one renderer cannot stop reaching it and
// still pass.
// and the index values whose route through hasValidAddress() differs from the
// row's own hostile set.
describe("a restore-only hostile value onto", () => {
for (const row of CONTRACT) {
for (const entry of row.hostileRestore || []) {
for (const view of entry.views) {
for (const view of entry.views || RESTORABLE_VIEWS) {
test(`${view}: ${row.field} = ${JSON.stringify(
entry.value,
)}`, async () => {
const env = await bootPopup(
restoringOnto(view, { [row.field]: entry.value }),
);
expect({
errors: env.pageErrors,
visible: env.visibleViews(),
}).toEqual({
errors: [],
visible: [entry.restored ? view : "main"],
});
await expect(
bootHealth(
restoringOnto(view, { [row.field]: entry.value }),
),
).resolves.toEqual(HEALTHY);
});
}
}