Compare commits

..

5 Commits

Author SHA1 Message Date
b2b5514566 fix: answer the page when a background handler throws (closes #280)
All checks were successful
check / check (push) Successful in 35s
e2e / e2e-chrome (push) Successful in 45s
e2e / e2e-firefox (push) Successful in 21s
handleRpc(...).then(sendResponse) had no .catch(), and sendResponse is the only
thing that settles the dApp's window.ethereum.request() promise. Any throw
inside handleRpc therefore sent nothing back: the content script posted nothing,
and the page's promise stayed pending forever with no error and no timeout,
indistinguishable from a slow wallet. handleRpc does real work -- state loads,
provider calls, transaction population, approval plumbing -- so "it does not
throw today" was not a property anyone was maintaining.

A rejected handleRpc now answers { code: -32603, message }. -32603 is the
JSON-RPC internal error EIP-1474 defines and EIP-1193 defers to for RPC-layer
failures; no EIP-1193 4xxx code describes "the wallet broke" and none was
invented for it. Nothing is stripped or overwritten by this: every deliberately
coded rejection the wallet emits (4001, 4100, 4902) is a returned value, not a
throw, so it travels the resolved path and never reaches this catch. The cause
is not put in the message: the page gets a stable sentence, the background
console gets the method and the throw, so the failure is visible rather than
swallowed.

The two async IIFEs behind AUTISTMASK_TX_RESPONSE and AUTISTMASK_SIGN_RESPONSE
are the same shape one level down. Every statement is inside a try, but a throw
from one of the catch blocks escapes as an unhandled rejection and neither the
popup nor the page is answered. Each gets a last-resort .catch() that settles
the approval through settleApproval() -- the existing chokepoint, with no new
delete or resolve -- and answers the popup. The transaction one tracks the phase
it is in and reports that: an escape from the verify catch runs before
broadcastTransaction() is ever called, so it says the request is gone rather
than that it may still have reached the network, and only an escape from the
broadcast catch keeps the warning about a second send. Every other message
handler on the path is synchronous and cannot leave a promise pending.

Each of the four tests is driven by a real failure rather than a hook in the
handler: a rejecting extension-storage read, which getState() awaits unguarded,
and a failure classifier that throws while classifying a genuine verification or
broadcast failure. All four were demonstrated failing against the unfixed code,
the RPC one with sendResponse at zero calls, which is precisely the page-side
hang. The two transaction cases also assert the sentence describeSigningFailure
builds for each stage, which is the copy the user reads.
2026-08-17 07:12:22 +00:00
47bf38644d build: add ESLint to script/lint and containerize linting (closes #152)
All checks were successful
check / check (push) Successful in 39s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 24s
2026-08-17 09:10:03 +02:00
4b7a678a9b refactor: one shared extension-API module, and drive the dApp flows on Firefox (closes #153)
All checks were successful
check / check (push) Successful in 27s
e2e / e2e-chrome (push) Successful in 45s
e2e / e2e-firefox (push) Successful in 23s
2026-08-17 09:06:13 +02:00
ab1c1846a7 fix: one wording for an empty password field on every screen (closes #265)
All checks were successful
check / check (push) Successful in 29s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 18s
2026-08-17 08:59:59 +02:00
743b1962a5 build: run the browser e2e suites in CI (closes #259)
All checks were successful
check / check (push) Successful in 28s
e2e / e2e-chrome (push) Successful in 47s
e2e / e2e-firefox (push) Successful in 20s
2026-08-17 08:52:26 +02:00
48 changed files with 2711 additions and 428 deletions

49
.gitea/workflows/e2e.yml Normal file
View File

@@ -0,0 +1,49 @@
name: e2e
on: [push]
# The browser end-to-end suites, one job per browser, deliberately kept out
# of the check workflow: REPO_POLICIES.md caps make test at 20 seconds and
# script/cibuild is a plain `docker build .` whose Dockerfile runs
# make check, so folding a browser suite into either would blow that cap
# and slow the local fast path. Before this workflow every browser-level
# guarantee in this repo held only when a human remembered to run it.
#
# One job per browser rather than two steps in one job, so a Chrome failure
# does not hide the Firefox result.
#
# Each job is one script and nothing else. Both scripts need docker and
# nothing else — they deliver the repo to the daemon as a build context and
# build the extension inside the pinned image — which is what makes them
# runnable here at all: the runner executes the job in a container against
# the host's docker socket, so a `-v "$PWD:/work"` source path is resolved
# by the host daemon and mounts an empty directory, and the runner image's
# node is too old to install this repo's dependencies.
#
# These jobs REPORT, they do not gate. Whether a check blocks a merge is
# Gitea branch protection, which this repo does not configure, so a failure
# here is a red mark a reviewer has to account for rather than a hard
# block. Making e2e-chrome a required check is blocked on the measured
# flake in the dApp signing wait -- two of six runs of unmutated code on a
# loaded machine -- tracked as
# https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at
# random teaches people to merge past red.
#
# Nothing here may pass vacuously. There is no continue-on-error and no
# `|| true`. Both scripts exit non-zero when docker is missing, when the
# image build fails, and when the browser fails to start; the Chrome
# harness aborts the suite outright if its network interception is not in
# effect.
jobs:
e2e-chrome:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/test-e2e
e2e-firefox:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/test-e2e-firefox

View File

@@ -1,8 +1,21 @@
# node:22-slim (22.x LTS), 2026-02-24
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base
WORKDIR /app
# Marks "already inside the lint container" for script/lint, which otherwise
# shells out to docker to build the lint stage below. Nothing outside this
# image sets it.
ENV AUTISTMASK_LINT_NATIVE=1
# script/test's default 30s bound is the host figure, against a suite that
# runs in about 8s there. In here the same suite starts on a cold jest cache
# and shares the runner with the rest of the build, so 30s is marginal rather
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
# still catches a hang in three minutes and cannot be tripped by a suite that
# is merely running on contended hardware.
ENV AUTISTMASK_TEST_TIMEOUT=180
# script/bootstrap installs all prerequisites (make via apt here; node
# is already in the base image, yarn comes via corepack) and runs
# yarn install --frozen-lockfile. Dependency manifests are copied first
@@ -13,5 +26,17 @@ RUN script/bootstrap
COPY . .
# Lint stage — fail fast on static analysis and formatting, before the tests
# and the build. This is also the stage script/lint builds from a host, which
# is how linting stays on the pinned ESLint rather than the host's.
FROM base AS lint
RUN make lint
# Full check and build. The COPY --from is a no-op file copy whose only job is
# to make BuildKit finish the lint stage before this one starts; without it the
# stages run in parallel and a lint failure would not fail the build early.
FROM base AS check
COPY --from=lint /app/package.json /dev/null
RUN make check
RUN make build

179
README.md
View File

@@ -83,12 +83,19 @@ provide:
git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run the test suite (jest)
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker required;
see [End-to-End Tests](#end-to-end-tests))
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (docker
required; builds its own pinned image, see
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker is the
only prerequisite: it builds a pinned image that carries the repo and a fresh
extension build, see [End-to-End Tests](#end-to-end-tests))
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same,
against an image with a pinned Firefox and geckodriver, see
[End-to-End Tests](#end-to-end-tests))
- `script/lint` — run the linter
- `script/lint` — run ESLint (`eslint.config.js`) and then `prettier --check`,
failing on either. It never writes: `--fix` is not in this path, so
`make check` stays non-mutating. Linting runs in the container — the script
builds the Dockerfile's `lint` stage — because an ESLint result that depends
on whichever ESLint the host happens to have is not a result. Docker is
therefore required to lint; inside that image `AUTISTMASK_LINT_NATIVE=1` makes
the same script lint in place instead of recursing.
- `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, test-verify-build, lint, and fmt-check
@@ -136,11 +143,12 @@ are outside `make check`.
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
Chrome**, loaded as an unpacked MV3 extension inside a pinned
`mcr.microsoft.com/playwright` container (pinned by digest in `script/test-e2e`;
docker is required and the suite fails loudly rather than skipping if it is
unavailable). The suite lives in `tests/e2e/` and is driven by
`playwright-core`, whose version must stay matched to the container's Playwright
version — the browsers ship inside the image.
`mcr.microsoft.com/playwright` container (pinned by digest in
`tests/e2e/Dockerfile`, which is also where the extension is built; docker is
required and the suite fails loudly rather than skipping if it is unavailable).
The suite lives in `tests/e2e/` and is driven by `playwright-core`, whose
version must stay matched to the container's Playwright version — the browsers
ship inside the image.
It covers popup load, WebAssembly compilation under the shipped CSP (see
[Content Security Policy](#content-security-policy)), wallet creation through
@@ -232,24 +240,37 @@ being intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is
printed, tagged `[sw]` or `[page]`.
**Any uncaught page error or `console.error` fails the run.** That is the point:
a `ReferenceError` from a used-but-not-imported identifier is invisible to
`make check` (`script/lint` is only `prettier --check`) but fatal in a browser,
and this suite exists because exactly that class of bug shipped twice.
this suite exists because a `ReferenceError` from a used-but-not-imported
identifier shipped twice, fatal in a browser and invisible to a `make check`
that was `prettier --check` only. ESLint's `no-undef` now catches that exact
class before a browser is involved, so this suite is no longer the only thing
standing between it and a release — but a static rule only sees identifiers, and
the runtime errors this suite catches are broader than one rule.
### Firefox (`make test-e2e-firefox`)
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
It covers popup load, wallet creation through the UI, and the Add Token screen.
The suite lives in `tests/e2e/firefox/` and has **no npm dependencies at all**:
it is a small WebDriver client built on global `fetch` and `child_process`
against geckodriver's HTTP API.
It covers popup load, wallet creation through the UI, the Add Token screen, and
the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
— driven through the real content script, background page and approval windows.
Unlike the Chrome suite it builds its own container image rather than pulling a
published one, because no published image carries both a pinned Firefox and a
matching geckodriver. `tests/e2e/firefox/Dockerfile` pins all three external
artifacts by digest — the `node` base image, the Firefox 153.0.3 tarball, and
geckodriver 0.36.0 — and the Firefox version in particular must not float:
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and
`child_process` against geckodriver's HTTP API. The dApp fixture (`dapp.js`) and
the assertions do use `ethers`, and have to — a signature is recovered in the
runner rather than believed from the extension, and the stub node has to answer
`eth_sendRawTransaction` with the hash `ethers` computes for the artifact it
sent, or `provider.broadcastTransaction()` refuses the answer.
Both suites build their own image, each with the repo and a fresh extension
build baked in; what differs is the base. The Chrome image layers those on top
of a published Playwright image, whereas this one is assembled from a `node`
base, because no published image carries both a pinned Firefox and a matching
geckodriver. `tests/e2e/firefox/Dockerfile` pins all three external artifacts by
digest — the `node` base image, the Firefox 153.0.3 tarball, and geckodriver
0.36.0 — and the Firefox version in particular must not float:
`-remote-allow-system-access` is **mandatory** on 153 and was not on 142.
Without that flag, both navigating to `moz-extension://` and running
chrome-context script fail with `unsupported operation`. The flag grants the
@@ -265,20 +286,39 @@ because BiDi's `browsingContext.navigate` refuses `moz-extension://` outright.
**Any uncaught error from a `moz-extension://` source fails the run**, including
errors from the background page, which the suite never navigates to: a `throw`
at the top of `src/background/index.js` kills the background page and fails
step 1. Content-script errors should arrive by the same route, but this suite
does not exercise it and does not claim it — with `--network none` there is no
`http://` page for a content script to be injected into. Errors from add-on
install and background startup are folded into step 1 rather than discarded.
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
`log.entryAdded` delivers **nothing** for extension pages: on a plain `http://`
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.
step 1. Content scripts **are** exercised now — the dApp steps drive a page
served from loopback, which survives `--network none` — but the _capture_ of a
content-script error by this route is still unproven: no probe has forced a
throw inside one and watched it fail the run, so it remains an expectation
rather than a demonstrated fact. Errors from add-on install and background
startup are folded into step 1 rather than discarded.
An **unhandled promise rejection counts as an uncaught error** on both suites,
which matters because a good deal of popup code is now `async` and called
without an `await`. Demonstrated, not assumed: a `throw` placed past the first
`await` of `approval.show()` — which nothing awaits — turns the
`eth_requestAccounts` step red on Firefox
(`uncaught extension errors during this step`, from the console-service drain)
and on Chrome (`pageerror`), with the rest of the run unaffected because the
approval view had already rendered.
One error is tolerated rather than fatal, listed in `ALLOWED_ERRORS` in
`tests/e2e/firefox/run.js` with the issue that will delete it, and printed on
every occurrence so the concession stays visible in the run output. It is
Firefox reporting the site-approval popup's unawaited `sendMessage` settling
after `window.close()` unloaded the context — the same teardown ordering as
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275), and unsuppressable from
the calling code, because `BaseContext.wrapPromise` reports it whether or not a
handler is attached. 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 `log.entryAdded` delivers **nothing** for extension pages: on a
plain `http://` 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:
@@ -302,24 +342,63 @@ Two limits are worth knowing, both real differences from the Chrome suite:
but a step that logs heavily could evict unread errors. What poll-based costs
is location, not coverage: an error cannot be placed within a step the way the
Chrome suite's `pageerror` events place 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.
Closing that gap needs a fixture layer, deliberately out of scope for this
harness.
- **Almost nothing is stubbed, which inverts the coverage of network-dependent
code.** The container still runs with `--network none`, so the run is offline
and no request can escape. The one thing it can reach is the loopback fixture
in `tests/e2e/firefox/dapp.js`, which serves the dApp page and a JSON-RPC node
and which the extension's `rpcUrl` is pointed at for the dApp steps; a
JSON-RPC method that fixture does not model fails the run rather than
answering `null`. Everything else — Blockscout, the price feed, the phishing
blocklist — has no fixture and simply fails, and the extension swallows its
own fetch failures, so only the _failure_ branches of that code are ever
executed. A `ReferenceError` in the success path of `renderTransactions`, or
of price rendering, passes this suite green. The offline run is also weaker
than the Chrome suite's interception for those calls: it proves nothing got
out, but it cannot report which requests were attempted.
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
suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot
pick it up either. Neither is wired into the Gitea workflow yet —
docker-in-docker in CI is a separate question. Run them locally before changing
anything under `src/popup/views/`.
pick it up either. Run them locally before changing anything under
`src/popup/views/`.
### In CI
`.gitea/workflows/e2e.yml` runs both suites on every push, as two jobs —
`e2e-chrome` and `e2e-firefox` — separate from the `check` workflow, so the
20-second `make test` cap and the local fast path are untouched. Each job is a
checkout and the matching `script/` entrypoint, nothing else.
Docker is the only thing either job needs from the runner, and that is not an
accident. The runner executes a job inside a container against the **host's**
docker daemon, so a `docker run -v "$PWD:/work"` source path is resolved by the
host and mounts an empty directory, and the runner image's node is too old to
install this repo's dependencies. Both suites therefore ship the repo to the
daemon as a build context and build the extension inside the image, which works
identically on a laptop.
The jobs **report, they do not gate.** A failure is a red mark against the
commit that a reviewer has to account for, not a hard block: whether a check
blocks a merge is Gitea branch protection, which this repo does not configure.
That is not only a statement about configuration. The Chrome suite is
**measurably flaky under load** — two of six runs of unmutated code on a busy
machine lost the approval popup out from under the dApp signing wait, always in
the `#183` section, tracked as
[#287](https://git.eeqj.de/sneak/AutistMask/issues/287). So a red `e2e-chrome`
has to be read before it is believed, and that flake is the blocker to ever
making this a required check. Do not answer it with a retry wrapper: a suite
that reruns until it is green stops being evidence.
Nothing in either job can pass vacuously. There is no `continue-on-error` and no
`|| true`; both scripts exit non-zero when docker is missing, when the image
build fails, and when the browser fails to start; the Chrome harness aborts the
suite outright if its network interception is not in effect.
Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
Firefox and geckodriver downloads the same way.
## Rationale

86
TODO.md
View File

@@ -32,19 +32,49 @@ 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) now sit alongside `make check`, which
cannot see a runtime `ReferenceError` in a popup view.
`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
Land [#152](https://git.eeqj.de/sneak/AutistMask/issues/152): add ESLint to
`script/lint`. `make check` is `prettier --check` only today and cannot catch
undefined identifiers, which is how
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) shipped.
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-08-17: One wording for an empty password field on every screen that asks
for one. The private key export screen said "Password is required." where the
other five say "Please enter your password.", the same one-condition-two-
wordings split that [#172](https://git.eeqj.de/sneak/AutistMask/issues/172)
closed for a rejected password. Strings only, no behaviour change.
`tests/passwordMessages.test.js` now pins the empty-field guard per call site
as well as the decrypt handler, anchored on the `decryptWithPassword` sites so
the wallet-creation screen — where an empty field means a password being
chosen, a different condition — stays out of the set. Every error container
measured at a 360px viewport in the pinned Playwright container: the export
screen's container holds at 20px with the following section at the same offset
for the old string, the new string and the empty reserved state
([#265](https://git.eeqj.de/sneak/AutistMask/issues/265)).
- 2026-08-17: One shared extension-API module,
[`src/shared/browserApi.js`](src/shared/browserApi.js), is the only place in
the tree that names `browser` or `chrome`. Every call site returns a promise;
`runtime.lastError` is gone. The same commit gives the Firefox suite the four
dApp round trips — `eth_requestAccounts`, `personal_sign`,
`eth_sendTransaction` and a closed approval window rejecting with EIP-1193
4001 — against a page and a JSON-RPC node served from loopback, which survives
`--network none`. **The premise of
[#153](https://git.eeqj.de/sneak/AutistMask/issues/153) does not survive that
harness**: Firefox's `browser.*` honours a trailing Chrome-style callback and
populates `runtime.lastError`, both measured directly on Firefox 153.0.3, and
all four flows pass against the unconverted code. What landed is a uniformity
and coverage change, not a repair of a broken target. `storageGet()` and
`storageSet()` **reject** where `storage.local` is absent rather than
resolving `{}` and a no-op write — they carry the wallet, and defaulting would
read an existing wallet back as none. The one caller that genuinely degrades,
[`src/shared/phishingDomains.js`](src/shared/phishingDomains.js), takes
`storageLocal()` directly and keeps its own null check.
- 2026-08-17: An address total no longer reports `$0.00` for holdings it cannot
price. Prices exist for the top 25 tokens only, so the priced-only sum was
printed as the total and an address holding nothing but unpriced ERC-20s was
@@ -111,6 +141,41 @@ undefined identifiers, which is how
on the real clipboard, read back after a sentinel write. Each of the four was
demonstrated failing against a deliberately broken build
([#188](https://git.eeqj.de/sneak/AutistMask/issues/188)).
- 2026-08-14: `make check` does static analysis. `script/lint` ran
`prettier --check .`, byte-identical to `script/fmt-check`, so a wallet with
two shipped used-but-not-imported crashes behind it was green. ESLint is now
pinned in `package.json` with `@eslint/js` recommended as the base, flat
config in `eslint.config.js`, `no-undef` and `no-unused-vars` error-level, and
globals declared per tree — browser for the popup and content scripts, service
worker for `src/background/` and `src/shared/`, jest for `tests/`, node for
`build.js`. It found 41 unused bindings and 53 undefined identifiers; all are
fixed, and dropping a call to an unimported `foo()` into any `src/` file fails
`make lint`. Linting is also containerized now: `script/lint` builds the
Dockerfile's new `lint` stage, so the ESLint that decides whether this repo is
green is the pinned one and not the host's. The lint stage roughly doubles the
image build, so `script/test`'s hard timeout is now a bound on a hung suite
rather than a wall-clock budget: 30s on the host, where the suite runs in
about 8s, and `AUTISTMASK_TEST_TIMEOUT` raises it inside the image, where a
cold build pays install and contention costs the policy budget never described
([#152](https://git.eeqj.de/sneak/AutistMask/issues/152)).
- 2026-08-14: CI runs the browser end-to-end suites. `.gitea/workflows/e2e.yml`
runs `script/test-e2e` and `script/test-e2e-firefox` as two jobs on every
push, separate from `check`, so `make check` and its 20-second `make test` cap
are untouched. Every browser-level guarantee in this repo — the WASM-under-CSP
check, the recovery-phrase and private-key DOM wipes, the ConfirmTx spend
gate, the dApp approval round trips — was enforced only when a human
remembered to run it by hand. The suites could not run on the runner as they
stood: the runner executes a job in a container against the host's docker
daemon, so `docker run -v "$PWD:/work"` mounts an empty directory (measured),
and the runner image's node cannot install this repo's dependencies. Both
suites now ship the repo to the daemon as a build context and build the
extension inside the pinned image, so docker is the only prerequisite on a
runner or a laptop, and both run the image by ID rather than by tag so
concurrent clones cannot swap it. The jobs report rather than gate — this repo
configures no branch protection, and the Chrome suite is measurably flaky
under load, filed as [#287](https://git.eeqj.de/sneak/AutistMask/issues/287)
rather than papered over
([#259](https://git.eeqj.de/sneak/AutistMask/issues/259)).
- 2026-08-14: A background message handler that throws now rejects the page
instead of hanging it. `handleRpc(...).then(sendResponse)` had no `.catch()`,
and `sendResponse` is the only thing that settles the dApp's
@@ -452,12 +517,5 @@ undefined identifiers, which is how
Only work that has no issue of its own belongs here; everything else is on the
tracker.
- 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.
- Decide whether docker-in-docker makes `make test-e2e` and
`make test-e2e-firefox` runnable in the Gitea workflow. Extending the Chrome
suite itself is tracked as
[#183](https://git.eeqj.de/sneak/AutistMask/issues/183).
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
land.

View File

@@ -63,7 +63,7 @@ function getBuildInfo() {
commitHash = execSync("git rev-parse --short HEAD", {
encoding: "utf8",
}).trim();
} catch (_) {
} catch {
// not a git repo or git not available
}
let commitHashFull = "unknown";
@@ -71,7 +71,7 @@ function getBuildInfo() {
commitHashFull = execSync("git rev-parse HEAD", {
encoding: "utf8",
}).trim();
} catch (_) {
} catch {
// not a git repo or git not available
}
return {

152
eslint.config.js Normal file
View File

@@ -0,0 +1,152 @@
// ESLint flat config. Static analysis for make check; formatting stays with
// prettier (script/fmt-check), so nothing here touches style.
//
// The sources are CommonJS and are bundled per entrypoint by build.js, so the
// globals differ by tree and are declared per tree below. Getting that wrong in
// either direction defeats the point: too few globals buries a real no-undef in
// false positives, too many hides the next unimported identifier.
const js = require("@eslint/js");
const globals = require("globals");
// The extension APIs. MV3 Chrome exposes `chrome`; Firefox exposes both, and
// the code feature-detects between them.
const extensionGlobals = {
chrome: "readonly",
browser: "readonly",
};
const commonjs = {
ecmaVersion: 2024,
sourceType: "commonjs",
};
module.exports = [
{
ignores: ["dist/", "node_modules/"],
},
js.configs.recommended,
{
rules: {
// The two rules this config exists for. Both are already
// error-level in the recommended set; restated so a future
// recommended-set change cannot silently downgrade them.
"no-undef": "error",
// `_`-prefixed arguments are the deliberate "present for the
// interface, unused here" marker: the popup views share one
// init(ctx) signature and three of the eight do not read ctx.
// An unused catch binding is written `catch {`, which the repo
// already does, so caught errors stay checked.
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
// Off tree-wide: it requires every rethrow to carry `{ cause }`,
// at 3 sites today (src/shared/balances.js 207 and 215,
// tests/e2e/firefox/run.js 131). That is a change to what the
// wallet's error paths actually throw, and it is a decision of its
// own rather than a side effect of turning a linter on — so it is
// off everywhere, including for new code, until that decision is
// made. Unlike no-useless-assignment below, this is not an
// accommodation of particular sites and must not be scoped to
// them.
"preserve-caught-error": "off",
},
},
// no-useless-assignment stays on everywhere except the two files that
// wipe decrypted key material: the `password = null` and
// `decryptedSecret = null` assignments after use are dead by construction
// — that is what a best-effort wipe is — and the rule's fix is to delete
// the wipe. 9 sites: approval.js 582, 593, 618, 648, 692, 703, 728, 764
// and confirmTx.js 459. Everything else in the tree is still checked, so
// an ordinary dead store elsewhere is still an error.
{
files: ["src/popup/views/approval.js", "src/popup/views/confirmTx.js"],
rules: {
"no-useless-assignment": "off",
},
},
// Popup and content scripts: page/window context.
{
files: ["src/popup/**/*.js", "src/content/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.browser, ...extensionGlobals },
},
},
// MV3 background: a service worker, with no window and no document.
{
files: ["src/background/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
},
// src/shared is bundled into both, so it may only use what both provide:
// the service worker globals are the intersection, plus the extension APIs.
{
files: ["src/shared/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
},
// src/shared/ens.js is the documented exception to the line above: its own
// header says POPUP ONLY, it caches in localStorage, and only popup views
// require it. Linting it as a service worker would be wrong about the file.
{
files: ["src/shared/ens.js"],
languageOptions: {
...commonjs,
globals: { ...globals.browser, ...extensionGlobals },
},
},
// Unit tests: jest on node.
{
files: ["tests/**/*.test.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node, ...globals.jest },
},
},
// The build script is a plain node program.
{
files: ["build.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
// The e2e harnesses are node programs that also carry, inline, the
// callbacks they ship into the browser via page.evaluate — so both
// contexts really are present in the same file and both sets of globals
// are in scope somewhere in it.
{
files: ["tests/e2e/**/*.js"],
languageOptions: {
...commonjs,
globals: {
...globals.node,
...globals.browser,
...extensionGlobals,
},
},
},
// This config file itself.
{
files: ["eslint.config.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
];

View File

@@ -9,13 +9,16 @@
"test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose",
"build": "node build.js",
"lint": "prettier --check .",
"lint": "eslint . && prettier --check .",
"fmt": "prettier --write .",
"fmt-check": "prettier --check ."
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@tailwindcss/cli": "^4.2.1",
"esbuild": "^0.27.3",
"eslint": "10.8.1",
"globals": "17.11.0",
"jest": "^30.2.0",
"playwright-core": "1.56.0",
"prettier": "^3.8.1",

View File

@@ -1,13 +1,51 @@
#!/bin/sh
# script/lint: run the linter.
# script/lint: run the linter (eslint, then prettier --check).
#
# Linting is containerized. ESLint results depend on the ESLint version, and
# the pinned one is the one in the image; a host's own install must not be
# able to decide whether this repo is green. From a host this therefore builds
# the Dockerfile's `lint` stage, which runs this same script inside the image.
#
# AUTISTMASK_LINT_NATIVE is set only in that image (see the Dockerfile) and is
# what stops the recursion, so `make check` inside the CI build lints in place
# instead of trying to reach a docker daemon it does not have.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
case "${AUTISTMASK_LINT_NATIVE:-}" in
1)
echo "Linting..."
yarn run lint 2>&1
return 0
;;
"") ;;
*)
# Set but not recognized: say so rather than silently taking the
# docker path, which would look like the variable had no effect.
echo "lint: AUTISTMASK_LINT_NATIVE is set to" \
"'${AUTISTMASK_LINT_NATIVE}'; the only recognized value is 1" >&2
exit 1
;;
esac
if ! command -v docker >/dev/null 2>&1; then
echo "lint: docker is required; linting does not run on the host" >&2
exit 1
fi
echo "Linting in the pinned container..."
# --progress=plain: the default progress renderer collapses the lint
# output on success, and a lint run whose output cannot be seen is not
# evidence that it ran.
#
# --output=type=cacheonly: the exit status is the whole result; exporting
# an image afterwards costs about ten times the lint itself.
docker build --progress=plain --target lint \
--output=type=cacheonly . 2>&1
}
main "$@"

View File

@@ -1,19 +1,49 @@
#!/bin/sh
# script/test: run the test suite.
#
# The timeout bounds a hung suite; it is not a performance budget. On a
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
# the bound. Inside the image the same suite also pays a cold jest cache and
# shares the runner with the rest of the build, which is not what that budget
# describes, so the Dockerfile raises the bound through
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
# produces a red that means nothing, and teaches "just run it again".
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
TIMEOUT="${AUTISTMASK_TEST_TIMEOUT:-30}"
main() {
cd "$ROOT"
echo "Running tests..."
timeout 30 yarn run test 2>&1 || {
echo "Running tests (timeout ${TIMEOUT}s)..."
status=0
timeout "$TIMEOUT" yarn run test 2>&1 || status=$?
[ "$status" -eq 0 ] && return 0
# 124 is timeout(1) killing the suite. Say so: a kill is not a failed
# assertion, and the verbose rerun would only spend the same wall clock
# to be killed again.
if [ "$status" -eq 124 ]; then
echo "tests: TIMED OUT after ${TIMEOUT}s (no assertion failed)" >&2
echo "tests: raise AUTISTMASK_TEST_TIMEOUT if the suite is healthy" >&2
exit 1
fi
# 125 is timeout(1) itself failing, which here means AUTISTMASK_TEST_TIMEOUT
# is not a duration it accepts. The suite never ran, so it neither timed out
# nor failed, and the verbose rerun would only reprint the same complaint.
if [ "$status" -eq 125 ]; then
echo "tests: DID NOT RUN: timeout(1) rejected AUTISTMASK_TEST_TIMEOUT=\"${TIMEOUT}\"" >&2
echo "tests: set it to a duration such as 30 or 180 (see timeout(1))" >&2
exit 1
fi
echo "--- Rerunning with --verbose for details ---"
timeout 30 yarn run test:verbose 2>&1 || true
timeout "$TIMEOUT" yarn run test:verbose 2>&1 || true
# Always fail: the first run already proved the tests are broken, so a
# flaky pass on the rerun must not turn the build green.
exit 1
}
}
main "$@"

View File

@@ -5,19 +5,32 @@
#
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
# caps make test at 20 seconds and a browser suite does not fit. Run it
# yourself before touching popup views; it is the only check that can see
# a used-but-not-imported identifier blow up at runtime.
# yourself before touching popup views. ESLint's no-undef now catches a
# used-but-not-imported identifier in make check, but only this suite sees
# what a view actually does when it runs.
# .gitea/workflows/e2e.yml also runs it on every push, in a job separate
# from check so that cap and the local fast path both stay intact.
#
# Docker is the only prerequisite. The repo reaches the container as a
# build context and the extension is built inside it (see
# tests/e2e/Dockerfile), so nothing here depends on the node, yarn or make
# on the machine that starts the run. That is not a convenience: a bind
# mount cannot work under Gitea Actions, and the runner image's node is too
# old to install this repo's dependencies.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# mcr.microsoft.com/playwright:v1.56.0-noble, 2026-08-09
#
# The playwright-core devDependency is pinned to the matching Playwright
# version (1.56.0) and the two must be bumped together: the browsers ship
# inside this image, and playwright-core looks for the exact browser
# revision its own version expects. A mismatch fails at launch.
IMAGE="mcr.microsoft.com/playwright@sha256:35246d87a7c88ea9b771c65d33171b2611b02a8253b4b12ce6f94376c55f99f2"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome"
IIDFILE=""
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
fi
}
main() {
cd "$ROOT"
@@ -27,14 +40,23 @@ main() {
exit 1
fi
echo "Building extension for e2e..."
yarn run build 2>&1
IIDFILE="$(mktemp)"
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
echo "Building the Chrome e2e image (extension included)..."
docker build --iidfile "$IIDFILE" -t "$IMAGE" -f tests/e2e/Dockerfile .
echo "Running e2e suite in the pinned Playwright container..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --ipc=host: Chromium's shared-memory needs more than the default
# 64MB /dev/shm or renderers crash.
# --user: keep files the suite touches owned by the caller, not root.
# HOME=/tmp: the mapped uid has no home directory in the image.
# HOME=/tmp: the image's root home is not a reliable place for the
# browser profile.
# PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1: without it,
# ctx.route() intercepts page requests only, and every fetch made by
# the MV3 background service worker — including the phishing
@@ -51,13 +73,10 @@ main() {
# on a deliberate bump.
docker run --rm \
--ipc=host \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
-v "$ROOT:/work" \
-w /work \
"$IMAGE" \
"$(cat "$IIDFILE")" \
node tests/e2e/run.js
}

View File

@@ -5,12 +5,17 @@
#
# Deliberately NOT called by script/check or script/test, for the same
# reason as the Chrome suite: REPO_POLICIES.md caps make test at 20 seconds
# and a browser suite does not fit.
# and a browser suite does not fit. .gitea/workflows/e2e.yml also runs it
# on every push, in a job separate from check.
#
# Unlike script/test-e2e this builds its image locally, because no
# Unlike script/test-e2e this builds its base image locally, because no
# published image carries both a pinned Firefox and a matching geckodriver.
# All three external artifacts are pinned by digest inside the Dockerfile;
# see tests/e2e/firefox/Dockerfile.
# see tests/e2e/firefox/Dockerfile, which also explains why the repo and
# the extension build are baked into the image rather than mounted.
#
# Docker is the only prerequisite: nothing here depends on the node, yarn
# or make on the machine that starts the run.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -18,6 +23,14 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
IIDFILE=""
cleanup() {
if [ -n "$IIDFILE" ]; then
rm -f "$IIDFILE"
fi
}
main() {
cd "$ROOT"
@@ -26,16 +39,20 @@ main() {
exit 1
fi
echo "Building extension for e2e..."
yarn run build 2>&1
IIDFILE="$(mktemp)"
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
# The build context is tests/e2e/firefox/ and holds nothing but the
# Dockerfile: the harness itself arrives over the bind mount below, so
# editing it never invalidates an image layer.
echo "Building the pinned Firefox e2e image..."
docker build -t "$IMAGE" "$ROOT/tests/e2e/firefox"
echo "Building the pinned Firefox e2e image (extension included)..."
docker build --iidfile "$IIDFILE" -t "$IMAGE" \
-f tests/e2e/firefox/Dockerfile .
echo "Running the Firefox e2e suite..."
# The image is run by ID, not by tag: where two clones of this repo run
# the suite at once, the other build can move the tag between this
# build and this run, and the suite would then silently test the other
# checkout.
#
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
# --network none: the suite stubs nothing, so this is what keeps the
# run offline and deterministic. The extension swallows its own
@@ -43,8 +60,8 @@ main() {
# network note in README.md. Weaker than the Chrome suite's
# fixture interception, and honestly so — it proves no request
# escaped, but it cannot report which ones were attempted.
# --user: keep files the suite touches owned by the caller, not root.
# HOME=/tmp: the mapped uid has no home directory in the image.
# HOME=/tmp: the image's root home is not a reliable place for the
# browser profile.
#
# No --privileged. Firefox's sandbox logs
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
@@ -52,11 +69,8 @@ main() {
docker run --rm \
--shm-size=1g \
--network none \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \
-v "$ROOT:/work" \
-w /work \
"$IMAGE" \
"$(cat "$IIDFILE")" \
node tests/e2e/firefox/run.js dist/firefox
}

View File

@@ -40,17 +40,21 @@ const {
registerAlarmHandlers,
} = require("../shared/alarms");
const storageApi =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const windowsApi =
typeof browser !== "undefined" ? browser.windows : chrome.windows;
const tabsApi = typeof browser !== "undefined" ? browser.tabs : chrome.tabs;
const actionApi =
typeof browser !== "undefined" ? browser.browserAction : chrome.action;
const {
actionApi,
runtimeApi,
storageGet,
tabsQuery,
tabsSendMessage,
windowsApi,
windowsCreate,
windowsGetLastFocused,
windowsRemove,
} = require("../shared/browserApi");
const runtime = runtimeApi();
const windowsNs = windowsApi();
const actionNs = actionApi();
// Connected sites (in-memory, non-persisted): { "origin:address": true }
const connectedSites = {};
@@ -177,7 +181,7 @@ const INTERNAL_ERROR_MESSAGE =
"AutistMask could not complete this request because of an internal error.";
async function getState() {
const result = await storageApi.get("autistmask");
const result = await storageGet("autistmask");
return (
result.autistmask || {
wallets: [],
@@ -241,8 +245,8 @@ async function proxyRpc(method, params) {
}
function resetPopupUrl() {
if (actionApi && typeof actionApi.setPopup === "function") {
actionApi.setPopup({ popup: "src/popup/index.html" });
if (actionNs && typeof actionNs.setPopup === "function") {
actionNs.setPopup({ popup: "src/popup/index.html" });
}
}
@@ -346,12 +350,21 @@ function releaseApproval(approval) {
// Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals.
function openApprovalWindow(id) {
// Never rejects. Its callers raise it from inside a Promise executor and drop
// the result on the floor, so a rejection here would be unhandled.
async function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 360;
const popupHeight = 600;
windowsApi.getLastFocused((currentWin) => {
let currentWin = null;
try {
currentWin = await windowsGetLastFocused();
} catch {
// Nothing focused to centre on. The window still opens, at whatever
// position the browser picks.
}
const opts = {
url: popupUrl,
type: "popup",
@@ -366,25 +379,31 @@ function openApprovalWindow(id) {
currentWin.top + (currentWin.height - popupHeight) / 2,
);
}
windowsApi.create(opts, (win) => {
let win = null;
try {
win = await windowsCreate(opts);
} catch (e) {
// The promise namespace reports the failure by rejecting where the
// callback namespace reported it by handing back no window; both land
// on the !win branch below, which settles the approval.
log.errorf("could not open the approval window:", e);
}
const approval = pendingApprovals[id];
if (!approval) {
// Settled while the window was opening — an address switch,
// say. Nothing is waiting on it, and a window showing an
// approval that no longer exists is not left on screen.
if (win) {
windowsApi.remove(win.id, () => {
if (runtime.lastError) {
// window already closed
}
});
}
// Settled while the window was opening — an address switch, say.
// Nothing is waiting on it, and a window showing an approval that no
// longer exists is not left on screen. The await above makes this a
// real race: writing the id back would resurrect a bare entry that
// nothing would ever resolve.
if (win) windowsRemove(win.id).catch(() => {});
return;
}
if (!win) {
// No window means no way to ever answer this approval, and an
// approval nothing can answer holds the requesting page's
// promise open forever. Settle it now instead.
// approval nothing can answer holds the requesting page's promise
// open forever. Settle it now instead.
settleApproval(
id,
abandonedResult(
@@ -396,8 +415,6 @@ function openApprovalWindow(id) {
return;
}
approval.windowId = win.id;
});
});
}
// Open an approval popup and return a promise that resolves with the user decision.
@@ -407,12 +424,12 @@ function requestApproval(origin, hostname) {
const id = crypto.randomUUID();
pendingApprovals[id] = { id, origin, hostname, resolve };
if (actionApi && typeof actionApi.openPopup === "function") {
actionApi.setPopup({
if (actionNs && typeof actionNs.openPopup === "function") {
actionNs.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
try {
const result = actionApi.openPopup();
const result = actionNs.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => openApprovalWindow(id));
}
@@ -479,7 +496,7 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
// Detect when an approval popup (browser-action) closes without a response.
// TX and sign approvals now use windows.create() and are handled by the
// windowsApi.onRemoved listener below, but we still handle site-connection
// windows.onRemoved listener below, but we still handle site-connection
// approval disconnects here.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
@@ -893,24 +910,26 @@ async function handleSendTransaction(params, origin) {
}
// Broadcast chainChanged to all tabs when the network is switched.
function broadcastChainChanged(chainId) {
tabsApi.query({}, (tabs) => {
//
// Never rejects: its caller is an RPC handler that must answer the page
// whatever the browser made of the broadcast.
async function broadcastChainChanged(chainId) {
let tabs;
try {
tabs = await tabsQuery({});
} catch {
return;
}
for (const tab of tabs) {
tabsApi.sendMessage(
tab.id,
{
// A tab with no content script has no receiver, and that is the
// ordinary case rather than a fault. The rejection it produces is the
// promise-shaped form of the runtime.lastError this used to read.
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: chainId,
},
() => {
if (runtime.lastError) {
// expected for tabs without our content script
}).catch(() => {});
}
},
);
}
});
}
// Broadcast accountsChanged to all tabs, respecting per-address permissions
@@ -931,18 +950,21 @@ async function broadcastAccountsChanged() {
);
if (!settleApproval(id, rejection)) continue;
if (approval.windowId) {
windowsApi.remove(approval.windowId, () => {
if (runtime.lastError) {
// window already closed
}
});
// Rejects when the window has already gone, which is a race the
// user wins routinely by closing it themselves.
windowsRemove(approval.windowId).catch(() => {});
}
}
resetPopupUrl();
const s = await getState();
const activeAddress = await getActiveAddress();
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
tabsApi.query({}, (tabs) => {
let tabs;
try {
tabs = await tabsQuery({});
} catch {
return;
}
for (const tab of tabs) {
const origin = tab.url ? new URL(tab.url).origin : "";
const hostname = extractHostname(origin);
@@ -950,22 +972,14 @@ async function broadcastAccountsChanged() {
activeAddress &&
(allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress]);
tabsApi.sendMessage(
tab.id,
{
// Same as chainChanged above: a tab without our content script
// rejects, and that is expected rather than a fault.
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: hasPermission ? [activeAddress] : [],
},
() => {
// Ignore errors for tabs without content script
if (runtime.lastError) {
// expected for tabs without our content script
}).catch(() => {});
}
},
);
}
});
}
// Background balance refresh: every 60 seconds when the popup isn't open.
@@ -1060,8 +1074,8 @@ startBackgroundJobs();
// outcome to the page — and the window is recorded as gone, so that an attempt
// which then fails retryably settles instead of waiting in a window that no
// longer exists.
if (windowsApi && windowsApi.onRemoved) {
windowsApi.onRemoved.addListener((windowId) => {
if (windowsNs && windowsNs.onRemoved) {
windowsNs.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue;
const rejection = abandonedResult(

View File

@@ -1,12 +1,20 @@
// AutistMask content script — bridges between inpage (window.ethereum)
// and the background service worker via extension messaging.
const {
hasBrowserNamespace,
runtimeApi,
sendMessage,
storageGet,
storageSet,
} = require("../shared/browserApi");
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
// in the manifest, so no injection is needed here. In Firefox (MV2), the
// "world" key is not supported, so we inject via a <script> tag.
if (typeof browser !== "undefined") {
if (hasBrowserNamespace()) {
const script = document.createElement("script");
script.src = browser.runtime.getURL("src/content/inpage.js");
script.src = runtimeApi().getURL("src/content/inpage.js");
script.onload = function () {
this.remove();
};
@@ -14,23 +22,27 @@ if (typeof browser !== "undefined") {
}
// Send the persisted EIP-6963 provider UUID to the inpage script.
// Generated once at install time and stored in chrome.storage.local.
(function sendProviderUuid() {
const storage =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
storage.get("eip6963Uuid", (items) => {
let uuid = items?.eip6963Uuid;
// Generated once at install time and stored in extension storage.
(async function sendProviderUuid() {
let uuid = null;
try {
const items = await storageGet("eip6963Uuid");
uuid = items?.eip6963Uuid;
if (!uuid) {
uuid = crypto.randomUUID();
storage.set({ eip6963Uuid: uuid });
await storageSet({ eip6963Uuid: uuid });
}
} catch {
// Storage was unavailable or refused the write. The announcement
// still has to go out — a provider that never announces is invisible
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
// page load will not outlive.
if (!uuid) uuid = crypto.randomUUID();
}
window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin,
);
});
})();
// Relay requests from the page to the background script
@@ -39,27 +51,31 @@ window.addEventListener("message", (event) => {
if (event.data?.type !== "AUTISTMASK_REQUEST") return;
const { id, method, params } = event.data;
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.sendMessage(
{ type: "AUTISTMASK_RPC", id, method, params, origin: location.origin },
(response) => {
sendMessage({
type: "AUTISTMASK_RPC",
id,
method,
params,
origin: location.origin,
})
.then((response) => {
if (response) {
window.postMessage(
{ type: "AUTISTMASK_RESPONSE", id, ...response },
"*",
);
}
},
);
})
.catch(() => {
// No receiver: the background context is gone. The page's promise
// stays pending, which is what it did before this was a promise
// at all; turning it into a rejection here is a change to what
// dApps see and belongs to its own issue.
});
});
// Listen for events pushed from the background (e.g. accountsChanged)
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.onMessage.addListener((msg) => {
runtimeApi().onMessage.addListener((msg) => {
if (msg.type === "AUTISTMASK_EVENT") {
window.postMessage(
{

View File

@@ -79,7 +79,7 @@
for (const cb of cbs) {
try {
cb(data);
} catch (e) {
} catch {
// ignore listener errors
}
}

View File

@@ -12,7 +12,6 @@ const {
setBackRenderer,
pushCurrentView,
goBack,
clearViewStack,
} = require("./views/helpers");
const { applyTheme } = require("./theme");
// Renders a view the popup lands on without having navigated to it forward:
@@ -161,6 +160,12 @@ async function init() {
const params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval");
if (approvalId) {
// Deliberately not awaited, and deliberately not .catch()ed. show()
// is async, so a throw past its first await surfaces as an unhandled
// rejection rather than an uncaught error — measured as still failing
// the run on both harnesses (Playwright `pageerror`, and the Firefox
// driver's console-service drain), so nothing is lost by leaving it
// on that path.
approval.show(approvalId);
showView("approve-site");
return;

View File

@@ -194,7 +194,7 @@ async function importPrivateKey(ctx) {
let addr;
try {
addr = addressFromPrivateKey(key);
} catch (e) {
} catch {
showFlash("Invalid private key.");
return;
}
@@ -246,7 +246,7 @@ async function importXprvKey(ctx) {
let result;
try {
result = hdWalletFromXprv(xprv);
} catch (e) {
} catch {
showFlash(
"That extended private key is not valid. Please check it and try again.",
);

View File

@@ -12,7 +12,7 @@ const {
goBack,
pushCurrentView,
} = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state");
const { state, saveState } = require("../../shared/state");
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
const {
fetchRecentTransactions,
@@ -44,7 +44,6 @@ function show() {
state.selectedToken = null;
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
const wi = state.selectedWallet;
const ai = state.selectedAddress;
$("address-title").textContent =
wallet.name + " \u2014 Address " + (ai + 1);
@@ -246,7 +245,6 @@ function renderTransactions(txs) {
row.addEventListener("click", () => {
const idx = parseInt(row.dataset.tx, 10);
const tx = loadedTxs[idx];
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
tx.fromEns = ensNameMap.get(tx.from) || null;
tx.toEns = ensNameMap.get(tx.to) || null;
ctx.showTransactionDetail(tx);

View File

@@ -16,7 +16,7 @@ const {
goBack,
pushCurrentView,
} = require("./helpers");
const { state, currentAddress, saveState } = require("../../shared/state");
const { state, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices");
const {

View File

@@ -9,7 +9,7 @@ const {
attachCopyHandlers,
onViewLeave,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { state, saveState } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks");
const {
formatEther,
@@ -27,8 +27,7 @@ const { walletDefect } = require("../../shared/walletDefects");
const { describeSigningFailure } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI);
@@ -439,10 +438,20 @@ function showSignApproval(details) {
);
}
function show(id) {
// Awaited by nobody: the popup entry point calls this and moves on. It
// therefore has to absorb its own failure, and a background that cannot
// describe the approval is the same outcome as an approval that is gone.
async function show(id) {
approvalId = id;
runtime.connect({ name: "approval:" + id });
runtime.sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }, (details) => {
runtimeApi().connect({ name: "approval:" + id });
let details = null;
try {
details = await sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id });
} catch {
details = null;
}
if (!details) {
window.close();
return;
@@ -461,12 +470,9 @@ function show(id) {
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(
state.activeAddress,
);
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice;
});
}
let approvalId = null;
@@ -537,7 +543,7 @@ function clearSignPassword() {
hideError("approve-sign-error");
}
function init(ctx) {
function init(_ctx) {
onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword);
@@ -548,7 +554,7 @@ function init(ctx) {
$("btn-approve").addEventListener("click", () => {
const remember = $("approve-remember").checked;
runtime.sendMessage({
notify({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: true,
@@ -559,7 +565,7 @@ function init(ctx) {
$("btn-reject").addEventListener("click", () => {
const remember = $("approve-remember").checked;
runtime.sendMessage({
notify({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: false,
@@ -648,7 +654,16 @@ function init(ctx) {
decryptedSecret = null;
}
runtime.sendMessage(payload, (response) => {
// A send that never reaches the background is reported to the user
// the same way a background that refused it is: describeSigningFailure
// turns a null response into the generic message below.
let response = null;
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.txHash) {
txStatus.showWait(pendingTxDetails, response.txHash);
return;
@@ -667,10 +682,9 @@ function init(ctx) {
txStatus.showError(pendingTxDetails, null, outcome.message);
}
});
});
$("btn-reject-tx").addEventListener("click", () => {
runtime.sendMessage({
notify({
type: "AUTISTMASK_TX_RESPONSE",
id: approvalId,
approved: false,
@@ -764,7 +778,13 @@ function init(ctx) {
decryptedSecret = null;
}
runtime.sendMessage(payload, (response) => {
let response = null;
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.signature) {
window.close();
return;
@@ -780,10 +800,9 @@ function init(ctx) {
showError("approve-sign-error", outcome.message);
if (outcome.retryable) setSignButtonBusy(false);
});
});
$("btn-reject-sign").addEventListener("click", () => {
runtime.sendMessage({
notify({
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: false,

View File

@@ -2,20 +2,12 @@
// Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts.
const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const { parseEther, parseUnits, formatEther, Contract } = require("ethers");
const {
$,
showError,
hideError,
showView,
showFlash,
flashCopyFeedback,
addressTitle,
escapeHtml,
renderAddressHtml,
@@ -23,7 +15,7 @@ const {
goBack,
onViewLeave,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const { state } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
@@ -399,7 +391,7 @@ function clearPassword() {
hideError("confirm-tx-password-error");
}
function init(ctx) {
function init(_ctx) {
onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => {
@@ -421,7 +413,7 @@ function init(ctx) {
wallet.encryptedSecret,
password,
);
} catch (e) {
} catch {
showError(
"confirm-tx-password-error",
"That password is incorrect. Please try again.",

View File

@@ -73,7 +73,7 @@ function init(_ctx) {
// Verify password against the wallet's encrypted data
try {
await decryptWithPassword(wallet.encryptedSecret, pw);
} catch (_e) {
} catch {
$("delete-wallet-flash").textContent =
"That password is incorrect. Please try again.";
$("delete-wallet-flash").style.visibility = "visible";

View File

@@ -112,7 +112,7 @@ function show(walletIdx, addrIdx) {
async function reveal() {
const password = $("export-privkey-password").value;
if (!password) {
fail("Password is required.");
fail("Please enter your password.");
return;
}
if (walletIndex === null) {

View File

@@ -2,7 +2,6 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
balanceLinesForAddress,
isoDate,
timeAgo,
@@ -15,6 +14,7 @@ const {
pushCurrentView,
} = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state");
const { notify } = require("../../shared/browserApi");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -292,11 +292,7 @@ function render(ctx) {
state.activeAddress = addr;
await saveState();
render(ctx);
const runtime =
typeof browser !== "undefined"
? browser.runtime
: chrome.runtime;
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
}
});
});

View File

@@ -57,7 +57,7 @@ function show() {
attachCopyHandlers("view-receive");
}
function init(ctx) {
function init(_ctx) {
$("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full;
if (addr) {

View File

@@ -4,7 +4,6 @@ const {
$,
showFlash,
addressTitle,
escapeHtml,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -210,7 +209,7 @@ function init(_ctx) {
}
resolvedTo = resolved;
ensName = to;
} catch (e) {
} catch {
showFlash("Failed to resolve ENS name.");
return;
}

View File

@@ -14,7 +14,6 @@ const {
parseDustThresholdGwei,
} = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet");
@@ -29,8 +28,7 @@ const {
GITEA_COMMIT_URL,
} = require("../../shared/buildInfo");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const { notify } = require("../../shared/browserApi");
let versionClickCount = 0;
let versionClickTimer = null;
@@ -61,7 +59,7 @@ function renderSiteList(containerId, siteMap, stateKey) {
}
}
await saveState();
runtime.sendMessage({ type: "AUTISTMASK_REMOVE_SITE" });
notify({ type: "AUTISTMASK_REMOVE_SITE" });
renderSiteList(containerId, state[key], key);
});
});

View File

@@ -23,8 +23,6 @@ const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
let ctx;
/**
* Determine a human-readable transaction type string from tx fields.
*/
@@ -166,7 +164,7 @@ function render() {
if (el) el.classList.add("hidden");
}
loadFullTxDetails(tx.hash, tx.to, tx.isContractCall);
loadFullTxDetails(tx.hash, tx.to);
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
@@ -274,7 +272,7 @@ function populateOnChainDetails(txData) {
}
}
async function loadFullTxDetails(txHash, toAddress, isContractCall) {
async function loadFullTxDetails(txHash, toAddress) {
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
@@ -349,8 +347,9 @@ async function loadFullTxDetails(txHash, toAddress, isContractCall) {
}
}
// The ctx this view is initialized with is unused: this module is the leaf of
// the navigation, and the other views reach it through their own ctx.
function init(_ctx) {
ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => {
goBack();
});

View File

@@ -12,7 +12,7 @@ const {
clearViewStack,
} = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { state, currentNetwork } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -229,10 +229,6 @@ function tokenLabel(address) {
return t ? t.symbol : null;
}
function etherscanTokenLink(address) {
return `${currentNetwork().explorerUrl}/token/${address}`;
}
function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`;

View File

@@ -19,6 +19,8 @@
// be bypassed on the scheduled tick — see backgroundRefresh() in
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js.
const { alarmsApi } = require("./browserApi");
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
@@ -26,14 +28,10 @@ const MIN_ALARM_PERIOD_MINUTES = 1;
const BALANCE_REFRESH_PERIOD_MINUTES = 1;
const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60;
// Resolved on use rather than captured at module load: the worker is torn
// alarmsApi() resolves on use rather than at module load: the worker is torn
// down and re-evaluated repeatedly, and tests install a stub after requiring
// the module.
function alarmsApi() {
if (typeof browser !== "undefined" && browser.alarms) return browser.alarms;
if (typeof chrome !== "undefined" && chrome.alarms) return chrome.alarms;
return null;
}
// this module. It returns null where the API is absent, which is why every
// entry point below degrades instead of throwing.
/**
* Create an alarm unless one with the requested period already exists.

262
src/shared/browserApi.js Normal file
View File

@@ -0,0 +1,262 @@
// The one place in this tree that names `browser` or `chrome`.
//
// The two targets do not agree on the namespace, and they disagree about the
// call shape only in which one is native. Chrome MV3 exposes `chrome.*`,
// where tabs, windows and messaging take a trailing callback and report
// failure through the global `chrome.runtime.lastError`. Firefox MV2 exposes
// `browser.*`, where those same methods return promises — but, measured on
// Firefox 153.0.3, it ALSO honours a trailing Chrome-style callback, returns
// no promise when one is given, and populates `browser.runtime.lastError`.
// The callback code that predated this module therefore ran on both, and
// https://git.eeqj.de/sneak/AutistMask/issues/153 was filed on the belief
// that it did not. This module exists for uniformity, not for repair: the
// tree used to resolve the namespace with a ternary at six call sites and
// then mix promise-form storage with callback-form messaging.
//
// The strategy is promises out, everywhere: one namespace, one call shape,
// composing with the `async` handlers in the background. Callers `await`;
// nothing outside this file has to know which browser it is running on.
//
// Two deliberate asymmetries, because they are what the browsers actually do
// rather than what a uniform-looking shim would pretend:
//
// - Storage is called in its PROMISE form on both namespaces.
// `chrome.storage.local.get()` returns a promise on MV3 and the popup
// already depends on that — src/shared/state.js has always awaited it.
// Wrapping it in a callback here would be a change, not a fix.
// - notify() sends without a callback. It is for a message whose answer
// nobody reads; appending a callback would only manufacture a
// lastError/rejection for a receiver that was never expected to reply.
//
// Everything is resolved on use rather than captured at module load. The MV3
// service worker is torn down and re-evaluated repeatedly, and the unit
// suite installs its stubs on `global.chrome` around a require().
// The extension API namespace, preferring `browser.*` where it exists.
//
// Whole-namespace, never per-method: mixing `browser.tabs` with
// `chrome.windows` would also mix promise and callback semantics inside a
// single call path, which is the bug this module exists to remove.
function extensionApi() {
if (typeof browser !== "undefined" && browser) return browser;
if (typeof chrome !== "undefined" && chrome) return chrome;
return null;
}
// True when the resolved namespace is the promise-flavoured one.
//
// It doubles as "this is the Gecko/MV2 build", which is a second question
// with the same answer and one real caller: src/content/index.js has to
// inject the inpage provider itself there, because MV2 has no
// `"world": "MAIN"` for a manifest-declared content script.
function hasBrowserNamespace() {
return typeof browser !== "undefined" && !!browser;
}
function namespaceMember(name) {
const api = extensionApi();
return (api && api[name]) || null;
}
function runtimeApi() {
return namespaceMember("runtime");
}
function tabsApi() {
return namespaceMember("tabs");
}
function windowsApi() {
return namespaceMember("windows");
}
function alarmsApi() {
return namespaceMember("alarms");
}
// The toolbar button. MV3 calls it `action`, MV2 calls it `browserAction`.
function actionApi() {
const api = extensionApi();
if (!api) return null;
return api.action || api.browserAction || null;
}
// `storage.local`, or null in a context that has no storage permission.
//
// Null rather than a throw for the one caller that genuinely degrades:
// src/shared/phishingDomains.js falls back to its vendored blocklist and does
// its own null check. Everything that reads or writes the wallet goes through
// storageGet()/storageSet(), which reject instead — see there.
function storageLocal() {
const storage = namespaceMember("storage");
return (storage && storage.local) || null;
}
// The callback-path error channel. Read only from inside an appended
// callback, i.e. only on the `chrome.*` path, where it is the sole way a
// failure is reported. The background's three explicit lastError checks are
// gone because invoke() turns it into a rejection before any caller sees it.
function lastError() {
const runtime = runtimeApi();
return (runtime && runtime.lastError) || null;
}
// Call `owner[method](...args)` and return a promise for its result.
//
// On the promise namespace the method already returns one. On the callback
// namespace the callback is appended here and lastError becomes a rejection,
// because a caller holding a promise has nowhere to check a global flag.
function invoke(owner, method, ...args) {
if (!owner || typeof owner[method] !== "function") {
return Promise.reject(
new Error(
"extension API " +
method +
"() is not available in this context",
),
);
}
if (hasBrowserNamespace()) {
try {
return Promise.resolve(owner[method](...args));
} catch (e) {
return Promise.reject(e);
}
}
return new Promise((resolve, reject) => {
owner[method](...args, (result) => {
const err = lastError();
if (err) reject(new Error(err.message || String(err)));
else resolve(result);
});
});
}
/**
* Send a message to the extension's own contexts and resolve with the reply.
*
* Rejects when nothing is listening, on both browsers. A caller that does not
* care must say so — see notify().
*
* @param {Object} message
* @returns {Promise<*>} the receiver's response.
*/
function sendMessage(message) {
return invoke(runtimeApi(), "sendMessage", message);
}
/**
* Send a message nobody is expected to answer, and swallow the fact that
* nobody did.
*
* @param {Object} message
* @returns {void}
*/
function notify(message) {
const runtime = runtimeApi();
if (!runtime || typeof runtime.sendMessage !== "function") return;
const result = runtime.sendMessage(message);
// MV3 hands back a promise for a one-argument send, and it rejects when
// the background is not listening. Unhandled, that surfaces as an error
// the e2e suites fail the run on.
if (result && typeof result.catch === "function") result.catch(() => {});
}
// These two carry the wallet. A missing `storage.local` has to reject and not
// default: resolving {} would make an existing wallet read back as no wallet,
// and resolving a no-op write would discard the user's state with nothing
// logged. A caller that wants to degrade takes storageLocal() directly.
function storageUnavailable(method) {
return Promise.reject(
new Error("extension storage.local is not available: " + method),
);
}
/**
* @param {string|string[]|Object} keys
* @returns {Promise<Object>} the stored items.
* @throws rejects where `storage.local` is absent.
*/
function storageGet(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("get");
return Promise.resolve(storage.get(keys));
}
/**
* @param {Object} items
* @returns {Promise<void>}
* @throws rejects where `storage.local` is absent.
*/
function storageSet(items) {
const storage = storageLocal();
if (!storage) return storageUnavailable("set");
return Promise.resolve(storage.set(items));
}
/**
* @param {Object} queryInfo
* @returns {Promise<Array>} the matching tabs.
*/
function tabsQuery(queryInfo) {
return invoke(tabsApi(), "query", queryInfo);
}
/**
* Send a message to one tab's content script.
*
* Rejects for a tab that has no receiver, which is most of them. That
* rejection is the promise-shaped replacement for the runtime.lastError
* checks the broadcast helpers used to make, and callers ignore it the same
* way.
*
* @param {number} tabId
* @param {Object} message
* @returns {Promise<*>}
*/
function tabsSendMessage(tabId, message) {
return invoke(tabsApi(), "sendMessage", tabId, message);
}
/**
* @param {Object} createData
* @returns {Promise<Object>} the created window.
*/
function windowsCreate(createData) {
return invoke(windowsApi(), "create", createData);
}
/**
* @returns {Promise<Object>} the last focused window.
*/
function windowsGetLastFocused() {
return invoke(windowsApi(), "getLastFocused");
}
/**
* @param {number} windowId
* @returns {Promise<void>}
*/
function windowsRemove(windowId) {
return invoke(windowsApi(), "remove", windowId);
}
module.exports = {
actionApi,
alarmsApi,
extensionApi,
hasBrowserNamespace,
notify,
runtimeApi,
sendMessage,
storageGet,
storageLocal,
storageSet,
tabsApi,
tabsQuery,
tabsSendMessage,
windowsApi,
windowsCreate,
windowsGetLastFocused,
windowsRemove,
};

View File

@@ -18,6 +18,7 @@
// its own refresh — see updatePhishingList().
const vendoredConfig = require("./phishingBlocklist.json");
const { storageLocal } = require("./browserApi");
const BLOCKLIST_URL =
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
@@ -46,18 +47,10 @@ let lastAttemptTime = 0;
let fetchPromise = null;
let loadPromise = null;
// Resolved on use rather than captured at module load, so a test can install
// a stub after requiring the module and so the popup — which has no reason to
// touch the delta — does not fail to load where the API is absent.
function storageApi() {
if (typeof browser !== "undefined" && browser.storage) {
return browser.storage.local;
}
if (typeof chrome !== "undefined" && chrome.storage) {
return chrome.storage.local;
}
return null;
}
// storageLocal() resolves on use rather than at module load, so a test can
// install a stub after requiring this module, and it returns null where the
// API is absent — which is why the popup, with no reason to touch the delta,
// loads fine without it.
/**
* Sanitise a timestamp read back from storage.
@@ -86,7 +79,7 @@ function sanitizeTimestamp(value) {
* @returns {Promise<void>}
*/
async function loadDeltaFromStorage() {
const storage = storageApi();
const storage = storageLocal();
if (!storage) return;
try {
const result = await storage.get(DELTA_STORAGE_KEY);
@@ -122,7 +115,7 @@ function ensureDeltaLoaded() {
* @returns {Promise<void>}
*/
async function saveDeltaToStorage() {
const storage = storageApi();
const storage = storageLocal();
if (!storage) return;
try {
const data = {

View File

@@ -21,7 +21,7 @@ async function refreshPrices() {
const fetched = await getTopTokenPrices(25);
Object.assign(prices, fetched);
lastFetchedAt = now;
} catch (e) {
} catch {
// prices stay stale on error
}
}

View File

@@ -5,10 +5,7 @@ const { networkById } = require("./networks");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const storageApi =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
const { storageGet, storageSet } = require("./browserApi");
const DEFAULT_STATE = {
hasWallet: false,
@@ -114,11 +111,11 @@ async function saveState() {
viewData: state.viewData,
viewStack: state.viewStack,
};
await storageApi.set({ autistmask: persisted });
await storageSet({ autistmask: persisted });
}
async function loadState() {
const result = await storageApi.get("autistmask");
const result = await storageGet("autistmask");
if (result.autistmask) {
const saved = result.autistmask;
state.wallets = saved.wallets || [];

View File

@@ -82,7 +82,7 @@ function toFixedPoint(value) {
if (text === "") return null;
try {
return parseUnits(text, SCALE_DECIMALS);
} catch (e) {
} catch {
return null;
}
}

View File

@@ -102,6 +102,11 @@ function decodeV2SwapExactIn(input) {
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser)
//
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
// its command name and no token or amount detail. Kept for the fix, which is
// https://git.eeqj.de/sneak/AutistMask/issues/283.
// eslint-disable-next-line no-unused-vars
function decodeV2SwapExactOut(input) {
try {
const d = coder.decode(

View File

@@ -57,7 +57,7 @@ async function cryptoBackend() {
try {
await WebAssembly.compile(EMPTY_WASM_MODULE);
return "wasm";
} catch (_) {
} catch {
return "asmjs";
}
}

View File

@@ -1,6 +1,8 @@
// Wallet and address deletion state transitions, kept out of the views so the
// selection and broadcast rules are testable without a DOM.
const { notify } = require("./browserApi");
// Two records of the same address can be stored in different cases, so
// address equality is never a literal string comparison.
function sameAddress(a, b) {
@@ -144,9 +146,7 @@ function removeAddressFromState(state, walletIdx, addrIdx) {
// accountsChanged to connected sites. Same call shape as the address
// switch in the home view.
function broadcastActiveChanged() {
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
}
module.exports = {

34
tests/e2e/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Chrome end-to-end image: the pinned Playwright image with this repo and a
# freshly built extension inside it, built by script/test-e2e. The suite is
# still started with `docker run`, so every runtime flag the harness needs
# (--ipc=host in particular) applies as before.
#
# The repo is baked in rather than bind-mounted because a bind mount does
# not resolve under Gitea Actions: the runner runs the job in a container
# against the HOST's docker socket, so the source side of a -v is resolved
# by the host daemon while the job's checkout lives on a docker volume that
# is not a host path -- the mount silently succeeds and /work is empty. A
# build context is streamed to the daemon and so works from anywhere.
# Building the extension here too means the machine starting a run needs
# docker and nothing else.
# mcr.microsoft.com/playwright:v1.56.0-noble, 2026-08-09
#
# The playwright-core devDependency is pinned to the matching Playwright
# version (1.56.0) and the two must be bumped together: the browsers ship
# inside this image, and playwright-core looks for the exact browser
# revision its own version expects. A mismatch fails at launch.
FROM mcr.microsoft.com/playwright@sha256:35246d87a7c88ea9b771c65d33171b2611b02a8253b4b12ce6f94376c55f99f2
WORKDIR /work
# Same layering as the root Dockerfile: script/bootstrap installs the
# prerequisites and the dependencies, and the manifests are copied first so
# that layer is cached until they change.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN make build

View File

@@ -1,10 +1,24 @@
# Firefox end-to-end image: stock Firefox plus geckodriver on a node base,
# built by script/test-e2e-firefox. The repo is bind-mounted at /work; the
# harness itself has no dependencies, so nothing is installed for it.
# with this repo and a freshly built extension inside it, built by
# script/test-e2e-firefox. The harness itself has no dependencies, so
# nothing is installed for it.
#
# All three external artifacts are pinned by digest. The Firefox version in
# particular must not float: -remote-allow-system-access is mandatory on 153
# and was not on 142, so the flag the harness passes is version-coupled.
# The build context is the repo root. The repo is baked in rather than
# bind-mounted because a bind mount does not resolve under Gitea Actions:
# the runner runs the job in a container against the HOST's docker socket,
# so the source side of a -v is resolved by the host daemon while the job's
# checkout lives on a docker volume that is not a host path -- the mount
# silently succeeds and /work is empty. Baking the build in is also the
# only way this suite can have both a built extension and the
# `--network none` it runs under, since a container with no network cannot
# install anything.
#
# All three external artifacts are pinned by digest, and are fetched in
# layers above the repo copy, so editing the harness or any source file
# re-runs only the two cheap layers at the bottom. The Firefox version in
# particular must not float: -remote-allow-system-access is mandatory on
# 153 and was not on 142, so the flag the harness passes is
# version-coupled.
# node:22-bookworm-slim, 2026-08-12
FROM node@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436
@@ -48,4 +62,16 @@ ENV FIREFOX_BIN=/opt/firefox/firefox
ENV GECKODRIVER=/usr/local/bin/geckodriver
WORKDIR /work
# Same layering as the root Dockerfile: script/bootstrap installs the
# prerequisites and the dependencies, and the manifests are copied first so
# that layer is cached until they change.
COPY script/ script/
COPY package.json yarn.lock ./
RUN script/bootstrap
COPY . .
RUN make build
CMD ["node", "tests/e2e/firefox/run.js", "dist/firefox"]

238
tests/e2e/firefox/dapp.js Normal file
View File

@@ -0,0 +1,238 @@
// A loopback dApp origin and stub Ethereum node for the Firefox suite.
//
// The Firefox container runs with --network none, and the harness note in
// driver.js records the consequence: with no http:// origin in reach, no
// content script was ever injected, so content-script behaviour was
// UNVERIFIED and the dApp flows could not be driven at all.
//
// --network none removes every interface except loopback, and loopback is
// enough. This serves the page and the JSON-RPC endpoint from 127.0.0.1
// inside the same container Firefox runs in, so the dApp round trips execute
// against a real http:// origin and the run stays as offline as it was: the
// only reachable peer is this process.
//
// The page itself is not written twice. DAPP_HTML comes from the Chrome
// suite's fixture, so both harnesses drive the same __dapp API and the same
// message log.
//
// Unlike driver.js this file does use ethers, and it has to: the node has to
// answer eth_sendRawTransaction with the hash ethers computes for the
// artifact it was handed, or provider.broadcastTransaction() refuses the
// answer, and the suite recovers signatures itself rather than believing the
// extension's own verdict.
"use strict";
const http = require("http");
const { Transaction } = require("ethers");
const { DAPP_HTML } = require("../network");
// The same fee shape the Chrome suite uses, for the same reason: it has to
// pass the ceilings in src/shared/approvalVerify.js and it has to leave the
// reserve and the estimate distinguishable.
const GAS_LIMIT = 21000n;
const BASE_FEE_WEI = 100000000000n; // 100 gwei
const PRIORITY_FEE_WEI = 1000000000n; // 1 gwei
const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI;
const STUB_BLOCK_NUMBER = 21000000;
// A 32-byte zero word, returned for every eth_call. It is what makes ethers'
// ENS reverse lookup resolve to "no resolver set" instead of throwing, and a
// throw there reaches the console through src/shared/log.js, which fails the
// run on its own.
const ZERO_WORD = "0x" + "0".repeat(64);
// One ETH, so the popup's balance lines render something and the wallet does
// not look empty on the approval screen.
const STUB_BALANCE_WEI = 10n ** 18n;
function hex(value) {
return "0x" + BigInt(value).toString(16);
}
function latestBlock() {
return {
hash: "0x" + "11".repeat(32),
parentHash: "0x" + "22".repeat(32),
number: hex(STUB_BLOCK_NUMBER),
timestamp: hex(1767326645),
nonce: "0x0000000000000000",
difficulty: "0x0",
gasLimit: "0x1c9c380",
gasUsed: "0xf4240",
miner: "0xc0ffee0000000000000000000000000000c0ffee",
extraData: "0x",
baseFeePerGas: hex(BASE_FEE_WEI),
transactions: [],
};
}
const RPC_RESULTS = {
eth_chainId: "0x1",
net_version: "1",
eth_blockNumber: hex(STUB_BLOCK_NUMBER),
eth_getBalance: hex(STUB_BALANCE_WEI),
eth_call: ZERO_WORD,
eth_getCode: "0x",
eth_gasPrice: hex(GAS_PRICE_WEI),
eth_estimateGas: hex(GAS_LIMIT),
eth_getTransactionCount: "0x0",
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
// "accepted but not mined", which is what a node says about a transaction
// it has only just taken. The wait screen the approval hands off to polls
// this for the rest of the run.
eth_getTransactionReceipt: null,
web3_clientVersion: "autistmask-e2e-firefox/0",
};
// Answer one JSON-RPC call. `broadcast` collects every raw transaction that
// reached this node, which is what the transaction assertions are made
// against — the artifact as the node saw it, never as the extension described
// it.
function rpcResult(req, state) {
const method = req.method;
if (method === "eth_sendRawTransaction") {
const raw = req.params && req.params[0];
state.broadcast.push(raw);
// ethers checks the hash it is given against the hash it computes for
// the artifact it sent, so this cannot be a fixed string.
return Transaction.from(raw).hash;
}
if (method === "eth_getBlockByNumber" || method === "eth_getBlockByHash") {
return latestBlock();
}
if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) {
return RPC_RESULTS[method];
}
// Never a silent default. An unstubbed method answered with null looks
// like a working node returning nothing, and the assertion downstream
// fails somewhere unrelated.
state.unstubbed.push(method);
throw new Error("no fixture for JSON-RPC method " + method);
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => resolve(body));
req.on("error", reject);
});
}
function handleRpcBody(body, state) {
const parsed = JSON.parse(body);
const answer = (req) => {
try {
return {
jsonrpc: "2.0",
id: req.id,
result: rpcResult(req, state),
};
} catch (e) {
return {
jsonrpc: "2.0",
id: req.id,
error: { code: -32601, message: e.message },
};
}
};
return Array.isArray(parsed) ? parsed.map(answer) : answer(parsed);
}
/**
* Serve the dApp page and the stub node on loopback.
*
* @returns {Promise<Object>} the running fixture: `url` and `origin` of the
* page, `rpcUrl` for the extension's rpcUrl setting, `broadcast` (the raw
* transactions the node received, in order), `unstubbed` (JSON-RPC methods
* nothing answered) and `close()`.
*/
async function startDappServer() {
const state = { broadcast: [], unstubbed: [], requests: [] };
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://127.0.0.1");
state.requests.push(req.method + " " + url.pathname);
if (url.pathname === "/rpc" && req.method === "POST") {
readBody(req)
.then((body) => {
const payload = JSON.stringify(handleRpcBody(body, state));
res.writeHead(200, {
"Content-Type": "application/json",
// The extension fetches this from its background
// page, whose origin is moz-extension://. Without CORS
// the fetch fails and every transaction assertion
// fails for a reason that has nothing to do with the
// wallet.
"Access-Control-Allow-Origin": "*",
});
res.end(payload);
})
.catch((e) => {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end(String(e && e.message));
});
return;
}
if (url.pathname === "/") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(DAPP_HTML);
return;
}
// An empty favicon rather than a 404: a 404 is a page error in
// Firefox's console under some settings, and the suite fails the run
// on those.
if (url.pathname === "/favicon.ico") {
res.writeHead(200, { "Content-Type": "image/x-icon" });
res.end("");
return;
}
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("not found");
});
await new Promise((resolve, reject) => {
server.on("error", reject);
// Port 0: this host runs many sessions at once, and a fixed port is a
// guaranteed collision rather than a possible one.
server.listen(0, "127.0.0.1", resolve);
});
const { port } = server.address();
const origin = "http://127.0.0.1:" + port;
return {
origin,
url: origin + "/",
rpcUrl: origin + "/rpc",
broadcast: state.broadcast,
unstubbed: state.unstubbed,
requests: state.requests,
close: () =>
new Promise((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
}),
};
}
module.exports = {
GAS_LIMIT,
GAS_PRICE_WEI,
STUB_BALANCE_WEI,
startDappServer,
};

View File

@@ -88,7 +88,7 @@ class Driver {
let parsed;
try {
parsed = JSON.parse(text);
} catch (_) {
} catch {
throw new Error(
method + " " + path + ": non-JSON response: " + text,
);
@@ -110,6 +110,26 @@ class Driver {
"extensions.webextensions.uuids": JSON.stringify({
[EXTENSION_ID]: EXTENSION_UUID,
}),
// The container has loopback and nothing else. Firefox's own
// link-status detection can read that as "offline" and then
// refuse every request, including the ones to the loopback dApp
// origin the suite serves; this takes the decision away from it.
"network.manage-offline-status": false,
// Force the site-connection prompt down its windows.create()
// fallback.
//
// src/background/index.js prefers the toolbar-anchored popup for
// that one approval and opens a real window only when
// openPopup() refuses. A panel is not a top-level browsing
// context, so WebDriver cannot see it, list it or click in it —
// the same blind spot the Chrome harness documents. Leaving this
// at its default would make which path runs depend on whether a
// headless Firefox counts as having had a user gesture, which is
// not a thing to leave to chance in a suite that has to be able
// to fail. The window path is shipped code and the same approval
// id, so what is driven is real; what is NOT covered either way
// is the panel presentation itself.
"extensions.openPopupWithoutUserGesture.enabled": false,
};
const value = await this.send("POST", "/session", {
@@ -179,6 +199,16 @@ class Driver {
return this.session("POST", "/execute/sync", { script, args });
}
// The asynchronous form: the script is handed a resolve callback as its
// last argument and the call settles when that is invoked. Everything
// interesting about an extension page is promise-shaped — storage reads,
// the provider's own request() — and /execute/sync cannot wait for any
// of it.
async executeAsync(script, args = []) {
await this.setContext("content");
return this.session("POST", "/execute/async", { script, args });
}
// Runs in the privileged chrome scope, where Services and Ci exist.
async executeChrome(script, args = []) {
await this.setContext("chrome");
@@ -199,7 +229,9 @@ class Driver {
// condition it was waiting on rather than "timed out".
async waitFor(what, script, args = [], timeout = DEFAULT_WAIT_MS) {
const deadline = Date.now() + timeout;
let last = null;
// Assigned on every path through the loop body before it is read, so
// there is no initializer to give it.
let last;
for (;;) {
try {
const v = await this.execute(script, args);
@@ -329,6 +361,63 @@ class Driver {
[selector],
);
}
// ------------------------------------------------------------ windows
//
// The approval prompts this suite drives are separate top-level windows
// the extension opens itself, so every one of them is a window handle
// here and the suite has to move between them explicitly.
async windowHandles() {
return this.session("GET", "/window/handles");
}
async currentWindow() {
return this.session("GET", "/window");
}
async switchToWindow(handle) {
await this.setContext("content");
await this.session("POST", "/window", { handle });
}
async newWindow(type = "window") {
await this.setContext("content");
const value = await this.session("POST", "/window/new", { type });
return value.handle;
}
// Closes the current window and leaves the session on `fallback`, because
// a session whose current window is gone fails every subsequent command
// with "no such window" rather than with anything diagnosable.
async closeWindow(fallback) {
await this.setContext("content");
await this.session("DELETE", "/window");
if (fallback) await this.switchToWindow(fallback);
}
async url() {
return this.session("GET", "/url");
}
// The handle of the first window whose URL matches, or null. Restores the
// window that was current before the search either way: a probe that
// silently relocates the session is a trap for the step after it.
async findWindow(predicate) {
const origin = await this.currentWindow();
try {
for (const handle of await this.windowHandles()) {
await this.switchToWindow(handle);
if (predicate(await this.url())) return handle;
}
return null;
} finally {
// Tolerated: the window the search started from may have been the
// one that just closed, and a throw in here would replace the
// real result with "no such window".
await this.switchToWindow(origin).catch(() => {});
}
}
}
// ------------------------------------------------------- error capture
@@ -349,10 +438,15 @@ class Driver {
// 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.
// the run.
//
// Content scripts ARE now exercised: tests/e2e/firefox/dapp.js serves a page
// from loopback, which survives --network none, and the suite drives the
// EIP-1193 round trips through the content script injected into it. What is
// still unproven is the CAPTURE, not the execution — no probe has forced a
// throw from inside a content script and watched it fail the run, so an
// uncaught content-script error arriving by this route remains an
// expectation rather than a demonstrated fact. Do not claim otherwise.
//
// Warnings are excluded so the semantics match Playwright's pageerror:
// uncaught errors only.
@@ -420,7 +514,7 @@ async function waitForDriverReady(base, timeoutMs) {
const body = await res.json();
if (body && body.value && body.value.ready !== false) return;
}
} catch (_) {
} catch {
// not listening yet
}
if (Date.now() >= deadline) {

View File

@@ -15,8 +15,15 @@
// UI steps below are written twice on purpose. Chrome runs on Playwright,
// which cannot see extension-page errors in Firefox at all (see the BiDi
// note in driver.js), so the two backends have no common substrate to
// abstract over. Three duplicated steps do not pay for a shim; revisit if
// this suite grows to where they do.
// abstract over. Duplicated steps do not pay for a shim; revisit if this
// suite grows to where they do. What IS shared is the dApp page fixture
// itself — DAPP_HTML, served here from loopback by dapp.js — so an assertion
// about the __dapp API means the same thing on both browsers.
//
// The dApp steps need an http:// origin, which --network none was thought to
// rule out. It does not: loopback survives it, so the page and the stub node
// are served from 127.0.0.1 inside the container and the run reaches nothing
// but this process. See tests/e2e/firefox/dapp.js.
//
// LIMITATION, and the difference from the Chrome suite worth knowing: error
// capture here is POLL-BASED, not event-streamed. The console service is
@@ -40,7 +47,21 @@
const fs = require("fs");
const path = require("path");
const {
Transaction,
formatEther,
getAddress,
getBytes,
hexlify,
parseEther,
toQuantity,
toUtf8Bytes,
verifyMessage,
} = require("ethers");
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
const { startDappServer } = require("./dapp");
const { STUB_COUNTERPARTY } = require("../network");
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
@@ -137,8 +158,598 @@ step("add token screen opens from address detail", async (env) => {
assert(picks > 0, "no common-token quick-pick buttons rendered");
});
// ------------------------------------------------- the dApp round trips
//
// Everything above drives the popup on its own. From here the page, the
// content script, the inpage provider, the background page and the approval
// window all have to work together — the paths
// https://git.eeqj.de/sneak/AutistMask/issues/153 rewrote, and the ones no
// Firefox test reached before. They are asserted here because nothing else
// covers them on this browser, not because they were broken: these steps
// pass against the pre-refactor callback code too, which is how the issue's
// premise was refuted.
//
// The shape is the Chrome suite's (tests/e2e/run.js, the #183 section) and
// the assertions mean the same things:
//
// - the signature is recovered here, in the runner, from the artifact the
// extension produced, and compared against the address read out of
// extension storage. The background verifies too; these assertions do not
// lean on that, because a test that trusted the wallet's own verdict would
// pass against a wallet that verified nothing.
// - the transaction is asserted against the raw signed transaction that
// reached the stub node, not against anything the extension reported.
//
// What this does NOT cover: a real dApp with real funds against a real
// network. The node is a fixture on loopback.
const SIGN_TEXT = "AutistMask e2e round trip: personal_sign";
const SIGN_HEX = hexlify(toUtf8Bytes(SIGN_TEXT));
const TX_VALUE_ETH = "0.0123";
const TX_VALUE_WEI = parseEther(TX_VALUE_ETH);
// Call data that decodes as nothing, so the screen assertion compares the
// calldata itself rather than a decoder's summary of it.
const TX_DATA = "0xdeadbeef" + "01".repeat(28);
const USER_REJECTION_MESSAGE = "User rejected the request.";
// Read the extension's persisted state, point its rpcUrl at the loopback stub
// node, and hand back the active address. Runs on the popup page, which is
// the one moz-extension:// document the suite has open and therefore the only
// place the storage API is reachable from.
async function pointAtStubNode(d, rpcUrl) {
const outcome = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const rpcUrl = arguments[0];
const api = typeof browser !== "undefined" ? browser : chrome;
Promise.resolve(api.storage.local.get("autistmask"))
.then((r) => {
const s = r.autistmask;
if (!s) throw new Error("the extension has no persisted state");
s.rpcUrl = rpcUrl;
const w = s.wallets && s.wallets[0];
const first = w && w.addresses && w.addresses[0];
const address = s.activeAddress || (first && first.address);
if (!address) throw new Error("the extension holds no address");
return Promise.resolve(api.storage.local.set({ autistmask: s }))
.then(() => done({ address: address }));
})
.catch((e) => done({ error: String((e && e.message) || e) }));`,
[rpcUrl],
);
assert(
outcome && !outcome.error,
"could not point the extension at the stub node: " +
(outcome && outcome.error),
);
return getAddress(outcome.address);
}
// The approval window the background opened. Approvals are raised from an RPC
// call rather than from a user gesture, so the extension opens a real window
// for them, which is an ordinary window handle here.
async function waitForApprovalWindow(d, timeout = 30000) {
const deadline = Date.now() + timeout;
for (;;) {
const handle = await d.findWindow((u) => u.includes("?approval="));
if (handle) return handle;
if (Date.now() > deadline) {
throw new Error(
"the extension opened no approval window within " +
timeout +
"ms",
);
}
await sleep(100);
}
}
function startRequest(d, key, method, params) {
return d.execute(
"window.__dapp.start(arguments[0], arguments[1], arguments[2]);" +
" return true;",
[key, method, params],
);
}
// The settled outcome of a parked request, or {settled:"pending"} if it is
// still outstanding. A bounded wait rather than a bare await: "returns a
// rejection rather than hanging" is one of the things under test, and an
// await would report a hang as a step timeout with no indication of which
// call never settled.
function settleRequest(d, key, timeout = 45000) {
return d.executeAsync(
`const done = arguments[arguments.length - 1];
const key = arguments[0];
const timeout = arguments[1];
Promise.race([
window.__dapp.settle(key),
new Promise((r) => setTimeout(() => r({ settled: "pending" }), timeout)),
]).then(done, (e) => done({ settled: "error", message: String(e) }));`,
[key, timeout],
);
}
// Every AUTISTMASK_* message that has crossed between the page and the
// content script. This is the boundary half of the rejection assertion: the
// code has to be on the wire as well as on the Error the page catches, so a
// pass cannot come from the provider inventing one.
function dappMessages(d, type) {
return d.execute(
// `want` is bound outside the callback deliberately: inside it,
// arguments[0] is the message being tested, not the script argument,
// and the filter silently matches nothing.
"var want = arguments[0];" +
" return window.__dapp.messages.filter(function (m) {" +
" return !want || m.type === want; });",
[type || null],
);
}
async function lastResponseError(d) {
const responses = await dappMessages(d, "AUTISTMASK_RESPONSE");
const last = responses[responses.length - 1];
assert(last, "the page received no AUTISTMASK_RESPONSE at all");
return last.error || null;
}
// A rejected prompt, asserted at both ends: the page's promise rejected
// rather than hanging or resolving, and the response that crossed the
// boundary carried EIP-1193 code 4001.
async function assertUserRejection(d, key, label) {
const outcome = await settleRequest(d, key);
assert(
outcome.settled !== "pending",
label + " never settled: the rejected prompt left the page hanging",
);
assert(
outcome.settled === "rejected",
label + " resolved instead of rejecting: " + JSON.stringify(outcome),
);
assert(
outcome.message === USER_REJECTION_MESSAGE,
label + " rejected with the wrong message: " + outcome.message,
);
const error = await lastResponseError(d);
assert(
error && error.code === 4001,
label +
" did not carry EIP-1193 code 4001 across the boundary: " +
JSON.stringify(error),
);
assert(
outcome.hasCode,
label +
" reached the page as an error with no code property at all, so a " +
"dApp cannot tell the user's refusal from a failure: " +
JSON.stringify(outcome),
);
assert(
outcome.code === 4001,
label +
" reached the page with code " +
JSON.stringify(outcome.code) +
" rather than EIP-1193 4001",
);
assert(
outcome.name === "ProviderRpcError",
label +
" reached the page as " +
JSON.stringify(outcome.name) +
" rather than an EIP-1193 ProviderRpcError",
);
console.log(
"# " +
label +
": code 4001 on the wire and on the page's " +
outcome.name,
);
}
step("the loopback dApp page gets the real inpage provider", async (env) => {
const d = env.driver;
// The popup is still the current window; point the extension at the stub
// node from there, then reload it so its in-memory copy of the state
// carries the new rpcUrl and cannot save the old one back over it.
env.address = await pointAtStubNode(d, env.server.rpcUrl);
await d.navigate(POPUP_URL);
await d.waitVisible("#view-main", STEP_TIMEOUT_MS);
env.popupWindow = await d.currentWindow();
env.dappWindow = await d.newWindow("tab");
await d.switchToWindow(env.dappWindow);
await d.navigate(env.server.url);
// window.ethereum is not the fixture's doing — it is the shipped content
// script, injected into a real http:// origin. Waiting for it is waiting
// for the real provider to have installed itself.
await d.waitFor(
"the injected EIP-1193 provider and the test page API",
"return !!window.ethereum && !!window.__dapp;",
[],
STEP_TIMEOUT_MS,
);
// EIP-6963, asked of the provider itself. The announcement carries the
// uuid src/content/index.js reads out of extension storage — call site 1
// in the issue — and it has to name this extension and hand back the very
// object on window.ethereum.
const announced = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const onAnnounce = (e) => {
window.removeEventListener("eip6963:announceProvider", onAnnounce);
done({
rdns: e.detail.info.rdns,
uuid: e.detail.info.uuid,
isWindowEthereum: e.detail.provider === window.ethereum,
});
};
window.addEventListener("eip6963:announceProvider", onAnnounce);
window.dispatchEvent(new Event("eip6963:requestProvider"));
setTimeout(() => done(null), 15000);`,
);
assert(announced, "the provider announced itself to no EIP-6963 request");
assert(
announced.rdns === "berlin.sneak.autistmask",
"the announced provider is not this extension: " +
JSON.stringify(announced),
);
assert(
announced.isWindowEthereum,
"the announced provider is not the object on window.ethereum",
);
assert(
typeof announced.uuid === "string" && announced.uuid.length === 36,
"the announcement carries no stored provider uuid: " +
JSON.stringify(announced.uuid),
);
// A full page -> content script -> background round trip that needs no
// approval, so the relay is proven before any prompt is driven. This is
// call site 2, the one that used to fail for every window.ethereum
// request a dApp made.
const chainId = await d.executeAsync(
`const done = arguments[arguments.length - 1];
window.ethereum.request({ method: "eth_chainId" }).then(
(r) => done({ ok: r }),
(e) => done({ err: String((e && e.message) || e) }),
);`,
);
assert(
chainId && chainId.ok === "0x1",
"eth_chainId did not round trip through the extension: " +
JSON.stringify(chainId),
);
console.log(
"# dapp origin " + env.server.origin + " active address " + env.address,
);
});
step(
"eth_requestAccounts approved returns the selected address",
async (env) => {
const d = env.driver;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "accounts", "eth_requestAccounts", []);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-site");
const hostname = await d.text("#approve-hostname");
assert(
hostname === "127.0.0.1",
"the site prompt names the wrong origin: " +
JSON.stringify(hostname),
);
const shown = await d.text("#approve-address");
assert(
shown.toLowerCase().includes(env.address.toLowerCase()),
"the site prompt shows the wrong address: " + JSON.stringify(shown),
);
// Remembered, so the origin stays authorized for the sign and transaction
// steps below.
const checked = await d.execute(
'return document.getElementById("approve-remember").checked;',
);
if (!checked) await d.click("#approve-remember");
await d.click("#btn-approve");
// The approve button closes its own window, so get off it before asking
// the page anything.
await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "accounts");
assert(
outcome.settled === "resolved",
"eth_requestAccounts did not resolve: " + JSON.stringify(outcome),
);
assert(
Array.isArray(outcome.result) && outcome.result.length === 1,
"eth_requestAccounts returned no single account: " +
JSON.stringify(outcome.result),
);
assert(
getAddress(outcome.result[0]) === env.address,
"eth_requestAccounts returned " +
outcome.result[0] +
", not the selected address " +
env.address,
);
},
);
step(
"personal_sign returns a signature that recovers to the address",
async (env) => {
const d = env.driver;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign", "personal_sign", [SIGN_HEX, env.address]);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-sign");
const screen = await d.execute(
`return {
hostname: document.getElementById("approve-sign-hostname").textContent,
type: document.getElementById("approve-sign-type").textContent,
message: document.getElementById("approve-sign-message").textContent,
from: document.getElementById("approve-sign-from").textContent,
};`,
);
assert(
screen.hostname === "127.0.0.1",
"the sign prompt names the wrong origin: " +
JSON.stringify(screen.hostname),
);
assert(
screen.type === "Personal message",
"the sign prompt reports the wrong type: " +
JSON.stringify(screen.type),
);
assert(
screen.message === SIGN_TEXT,
"the sign prompt shows the wrong message: " +
JSON.stringify(screen.message),
);
assert(
screen.from.toLowerCase().includes(env.address.toLowerCase()),
"the sign prompt shows the wrong signing address: " +
JSON.stringify(screen.from),
);
await d.fill("#approve-sign-password", PASSWORD);
await d.click("#btn-approve-sign");
await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "sign");
assert(
outcome.settled === "resolved",
"personal_sign did not resolve: " + JSON.stringify(outcome),
);
const recovered = getAddress(
verifyMessage(getBytes(SIGN_HEX), outcome.result),
);
console.log(
"# personal_sign: recovered=" +
recovered +
" expected=" +
env.address,
);
assert(
recovered === env.address,
"the personal_sign signature recovers to " +
recovered +
", not to the approved address " +
env.address,
);
},
);
step(
"eth_sendTransaction shows the transaction and returns its hash",
async (env) => {
const d = env.driver;
const before = env.server.broadcast.length;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "tx", "eth_sendTransaction", [
{
from: env.address,
to: STUB_COUNTERPARTY,
value: toQuantity(TX_VALUE_WEI),
data: TX_DATA,
},
]);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-tx");
const screen = await d.execute(
`return {
hostname: document.getElementById("approve-tx-hostname").textContent,
from: document.getElementById("approve-tx-from").textContent,
to: document.getElementById("approve-tx-to").textContent,
value: document.getElementById("approve-tx-value").textContent,
data: document.getElementById("approve-tx-data").textContent,
dataShown: !document
.getElementById("approve-tx-data-section")
.classList.contains("hidden"),
};`,
);
assert(
screen.hostname === "127.0.0.1",
"the transaction prompt names the wrong origin: " +
JSON.stringify(screen.hostname),
);
assert(
screen.from.toLowerCase().includes(env.address.toLowerCase()),
"the transaction prompt shows the wrong sender: " +
JSON.stringify(screen.from),
);
assert(
screen.to.toLowerCase().includes(STUB_COUNTERPARTY.toLowerCase()),
"the transaction prompt shows the wrong recipient: " +
JSON.stringify(screen.to),
);
assert(
screen.value.startsWith(TX_VALUE_ETH + " ETH"),
"the transaction prompt shows the wrong value: " +
JSON.stringify(screen.value),
);
assert(
screen.dataShown && screen.data === TX_DATA,
"the transaction prompt does not show the approved call data: " +
JSON.stringify(screen.data),
);
await d.fill("#approve-tx-password", PASSWORD);
await d.click("#btn-approve-tx");
// The approval window hands off to the wait screen rather than closing,
// and the hash it shows is asserted before it is retired: left open it
// polls the stub node for a receipt for the rest of the run.
await d.waitVisible("#view-wait-tx", STEP_TIMEOUT_MS);
const waitHash = await d.text("#wait-tx-hash");
await d.switchToWindow(env.dappWindow);
const outcome = await settleRequest(d, "tx");
assert(
outcome.settled === "resolved",
"eth_sendTransaction did not resolve: " + JSON.stringify(outcome),
);
// The artifact as the node saw it, not as the extension described it.
assert(
env.server.broadcast.length === before + 1,
"expected exactly one raw transaction to reach the node, got " +
(env.server.broadcast.length - before),
);
const signed = Transaction.from(
env.server.broadcast[env.server.broadcast.length - 1],
);
console.log(
"# eth_sendTransaction: signer=" +
getAddress(signed.from) +
" to=" +
getAddress(signed.to) +
" value=" +
formatEther(signed.value) +
" chainId=" +
signed.chainId,
);
assert(
getAddress(signed.from) === env.address,
"the broadcast transaction was signed by " +
getAddress(signed.from) +
", not by the approved address " +
env.address,
);
assert(
getAddress(signed.to) === getAddress(STUB_COUNTERPARTY),
"the broadcast transaction goes to " + signed.to,
);
assert(
signed.value === TX_VALUE_WEI,
"the broadcast transaction carries " +
formatEther(signed.value) +
" ETH, not the approved " +
TX_VALUE_ETH,
);
assert(
signed.data === TX_DATA,
"the broadcast transaction carries different call data: " +
signed.data,
);
assert(
signed.chainId === 1n,
"the broadcast transaction is for chain " + signed.chainId,
);
assert(
outcome.result === signed.hash,
"the page received " +
outcome.result +
", not the hash of the broadcast transaction " +
signed.hash,
);
assert(
waitHash.includes(signed.hash),
"the wait screen shows a different hash: " +
JSON.stringify(waitHash),
);
await d.switchToWindow(popup);
await d.closeWindow(env.dappWindow);
},
);
step(
"closing an approval window rejects the request with 4001",
async (env) => {
const d = env.driver;
const before = env.server.broadcast.length;
await d.switchToWindow(env.dappWindow);
await startRequest(d, "sign-closed", "personal_sign", [
SIGN_HEX,
env.address,
]);
const popup = await waitForApprovalWindow(d);
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-sign");
// Closed, not rejected: this is the windows.onRemoved path, which can
// only fire if windows.create() handed back a window id for the
// approval to be matched against — call site 4 in the issue, and the
// reason suppressing that write-back turns this step red.
await d.closeWindow(env.dappWindow);
await assertUserRejection(d, "sign-closed", "a closed approval window");
assert(
env.server.broadcast.length === before,
"a closed approval window still put a transaction on the node",
);
},
);
// ------------------------------------------------------------- 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 : "") + ")"
@@ -165,6 +776,21 @@ async function main() {
return;
}
// Loopback survives --network none, so this is the http:// origin the
// dApp steps need and the node they talk to. Started before the browser
// so its url is available to the first step that asks for it.
let server;
try {
server = await startDappServer();
} catch (e) {
console.error(
"e2e-firefox: cannot serve the dApp fixture: " + e.message,
);
process.exitCode = 1;
return;
}
console.log("# dapp fixture: " + server.url + " rpc " + server.rpcUrl);
let driver;
try {
driver = await start();
@@ -175,12 +801,20 @@ async function main() {
// absent suite. Never skip and report success.
console.error("e2e-firefox: cannot run the suite: " + e.message);
if (driver) await driver.quit().catch(() => {});
await server.close();
process.exitCode = 1;
return;
}
const errors = new ConsoleErrors(driver, EXTENSION_ORIGIN);
const env = { driver, phrase: null };
const env = {
driver,
server,
phrase: null,
address: null,
dappWindow: null,
popupWindow: null,
};
console.log("# extension origin: " + EXTENSION_ORIGIN);
console.log("1.." + steps.length);
@@ -232,6 +866,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) {
@@ -256,7 +904,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) +
@@ -273,12 +927,25 @@ async function main() {
);
for (const e of trailing) console.log("# " + formatError(e));
}
// A JSON-RPC method nothing answered means the extension asked the
// node something this fixture does not model, and whatever depended
// on the answer took the error branch instead. That is a hole in the
// fixture, not a pass.
if (server.unstubbed.length > 0) {
console.log(
"# FAILED: no fixture for JSON-RPC method(s) " +
[...new Set(server.unstubbed)].join(", "),
);
process.exitCode = 1;
}
if (failed > 0 || trailing.length > 0) {
console.log("# FAILED");
process.exitCode = 1;
}
} finally {
await driver.quit().catch(() => {});
await server.close();
}
}

View File

@@ -287,7 +287,7 @@ async function pageCompilesWasm(page) {
try {
await WebAssembly.compile(new Uint8Array(bytes));
return true;
} catch (_) {
} catch {
return false;
}
}, EMPTY_WASM_MODULE);

View File

@@ -93,6 +93,13 @@ function word(value) {
// is put there by the shipped manifest's MAIN-world content script, exactly
// as it is on any http(s) page a user visits, so what these tests speak to
// is the real inpage provider and not a copy the harness wired up.
//
// DAPP_HTML below is exported and served verbatim by the Firefox suite too
// (tests/e2e/firefox/dapp.js), from a loopback origin rather than through a
// route handler. The two suites drive different browsers over different
// protocols, but the page they drive — the __dapp API, the message log — is
// one fixture, so an assertion written against it means the same thing on
// both.
const DAPP_ORIGIN = "https://dapp.e2e.test";
const DAPP_URL = DAPP_ORIGIN + "/";
@@ -686,6 +693,7 @@ async function installNetworkStubs(ctx, opts) {
module.exports = {
installNetworkStubs,
DAPP_HTML,
DAPP_ORIGIN,
DAPP_URL,
FEE_ESTIMATE_WEI,

View File

@@ -68,7 +68,11 @@ function withTimeout(promise, name) {
const timeout = new Promise((_, reject) => {
timer = setTimeout(
() =>
reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")),
reject(
new Error(
name + ": timed out after " + TEST_TIMEOUT_MS + "ms",
),
),
TEST_TIMEOUT_MS,
);
});

View File

@@ -1,4 +1,4 @@
// One wording for one condition (issue #172).
// One wording for one condition (issues #172 and #265).
//
// Every screen that asks for the password decrypts the vault itself, and
// each one used to write its own sentence for the same failure: the send
@@ -21,6 +21,15 @@
// call site is read back to its own catch handler and the prose that
// handler shows the user must be the canonical sentence and nothing else
// — which fails on a novel wording, not only on a known-superseded one.
//
// The empty-password condition (#265) is pinned the same way and off the
// same call sites: the private key export screen said "Password is
// required." where the other five said "Please enter your password." Each
// decrypt's password variable is walked back to the guard that rejects it
// when blank, and the prose that guard shows must be the canonical
// sentence. Anchoring on the decrypt keeps the wallet-creation screen out
// of the set: an empty field there is a password being CHOSEN, a
// different condition with its own wording.
const fs = require("fs");
const path = require("path");
@@ -28,6 +37,7 @@ const path = require("path");
const SRC = path.join(__dirname, "..", "src");
const CANONICAL = "That password is incorrect. Please try again.";
const CANONICAL_EMPTY = "Please enter your password.";
// Wordings this repo has actually shipped for the same condition. This is
// a secondary, whole-file sweep for stragglers outside a decrypt handler;
@@ -36,6 +46,7 @@ const CANONICAL = "That password is incorrect. Please try again.";
const SUPERSEDED = [
"Wrong password.",
"That password is not correct. Please try again.",
"Password is required.",
];
function jsFilesUnder(dir) {
@@ -149,6 +160,71 @@ function handlerMessages(file, callOffset, label) {
.filter((v) => v.includes(" "));
}
// The identifier a decrypt call passes as its password, which is what the
// empty-field guard for that screen tests.
function passwordArg(masked, callOffset, label) {
const open = callOffset + "decryptWithPassword".length;
const args = [];
let depth = 0;
let start = open + 1;
for (let i = open; i < masked.length; i++) {
const c = masked[i];
if (c === "(" || c === "[" || c === "{") depth += 1;
else if (c === ")" || c === "]" || c === "}") {
depth -= 1;
if (depth === 0) {
args.push(masked.slice(start, i));
break;
}
} else if (c === "," && depth === 1) {
args.push(masked.slice(start, i));
start = i + 1;
}
}
const arg = (args[1] ?? "").trim();
if (!/^[A-Za-z_$][\w$]*$/.test(arg))
throw new Error(`${label}: password argument is not a name: ${arg}`);
return arg;
}
// Innermost block enclosing the decrypt that also declares its password
// variable — the handler the screen's submit button runs, which is where
// the empty-field guard lives.
function declaringBlock(masked, callOffset, ident, label) {
const declared = new RegExp(`\\b(?:const|let|var)\\s+${ident}\\s*=`);
let at = callOffset;
for (;;) {
const open = enclosingBlockStart(masked, at);
if (open === -1) throw new Error(`${label}: nothing declares ${ident}`);
const end = blockEnd(masked, open);
if (declared.test(masked.slice(open, end))) return [open, end];
at = open - 1;
}
}
// The prose the empty-field guard puts in front of the user. Exactly one
// guard per handler is required: two would mean the condition is answered
// in more than one place and this would be pinning only one of them.
function emptyGuardMessages(file, callOffset, label) {
const { masked, strings } = scan(fs.readFileSync(file, "utf8"));
const ident = passwordArg(masked, callOffset, label);
const [from, to] = declaringBlock(masked, callOffset, ident, label);
const guard = new RegExp(`if\\s*\\(\\s*!\\s*${ident}\\s*\\)\\s*\\{`, "g");
const opens = [];
let m;
while ((m = guard.exec(masked.slice(from, to))) !== null)
opens.push(from + m.index + m[0].length - 1);
if (opens.length !== 1)
throw new Error(
`${label}: expected one empty-${ident} guard, found ${opens.length}`,
);
const close = blockEnd(masked, opens[0]);
return strings
.filter((s) => s.offset >= opens[0] && s.offset < close)
.map((s) => s.value)
.filter((v) => v.includes(" "));
}
// The call sites are found, not listed: the file layout moves (the private
// key export was in addressDetail.js when #172 was filed and is its own
// view now), and a hardcoded list would quietly stop covering a screen it
@@ -187,8 +263,9 @@ describe("password failure messages", () => {
});
});
test("the canonical message is a full sentence", () => {
test("the canonical messages are full sentences", () => {
expect(CANONICAL).toMatch(/^[A-Z][^]*\.$/);
expect(CANONICAL_EMPTY).toMatch(/^[A-Z][^]*\.$/);
});
// Exact equality, per call site: a message that is merely different
@@ -203,6 +280,15 @@ describe("password failure messages", () => {
},
);
test.each(sites.map((s) => [s.label, s]))(
"%s answers an empty password field with the canonical sentence",
(label, site) => {
expect(emptyGuardMessages(site.file, site.offset, label)).toEqual([
CANONICAL_EMPTY,
]);
},
);
test.each(files.map((f) => [path.relative(SRC, f), f]))(
"%s carries no superseded wording",
(_rel, file) => {

View File

@@ -1,4 +1,4 @@
const { AbiCoder, Interface, solidityPacked, getBytes } = require("ethers");
const { AbiCoder, Interface, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap");
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";

380
yarn.lock
View File

@@ -427,6 +427,90 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
"@eslint-community/eslint-utils@^4.8.0":
version "4.10.1"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6"
integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/regexpp@^4.12.2":
version "4.12.2"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
"@eslint/config-array@^0.23.5":
version "0.23.5"
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95"
integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==
dependencies:
"@eslint/object-schema" "^3.0.5"
debug "^4.3.1"
minimatch "^10.2.4"
"@eslint/config-helpers@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377"
integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==
dependencies:
"@eslint/core" "^1.2.1"
"@eslint/core@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce"
integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==
dependencies:
"@types/json-schema" "^7.0.15"
"@eslint/js@10.0.1":
version "10.0.1"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
"@eslint/object-schema@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091"
integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==
"@eslint/plugin-kit@^0.7.2":
version "0.7.2"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz#4b0962f3f2c7ce8bc98b3ecfe34525c09d2cb729"
integrity sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==
dependencies:
"@eslint/core" "^1.2.1"
levn "^0.4.1"
"@humanfs/core@^0.19.2":
version "0.19.2"
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==
dependencies:
"@humanfs/types" "^0.15.0"
"@humanfs/node@^0.16.6":
version "0.16.8"
resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed"
integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==
dependencies:
"@humanfs/core" "^0.19.2"
"@humanfs/types" "^0.15.0"
"@humanwhocodes/retry" "^0.4.0"
"@humanfs/types@^0.15.0":
version "0.15.0"
resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090"
integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==
"@humanwhocodes/module-importer@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2":
version "0.4.3"
resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
"@isaacs/cliui@^8.0.2":
version "8.0.2"
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
@@ -1008,6 +1092,16 @@
dependencies:
"@babel/types" "^7.28.2"
"@types/esrecurse@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec"
integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==
"@types/estree@^1.0.6", "@types/estree@^1.0.8":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6":
version "2.0.6"
resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz"
@@ -1027,6 +1121,11 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/json-schema@^7.0.15":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/node@*", "@types/node@22.7.5":
version "22.7.5"
resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz"
@@ -1153,11 +1252,31 @@
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^8.16.0:
version "8.18.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940"
integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==
aes-js@4.0.0-beta.5:
version "4.0.0-beta.5"
resolved "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz"
integrity sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==
ajv@^6.14.0:
version "6.15.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492"
integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==
dependencies:
fast-deep-equal "^3.1.1"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ansi-escapes@^4.3.2:
version "4.3.2"
resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz"
@@ -1297,6 +1416,13 @@ brace-expansion@^5.0.2:
dependencies:
balanced-match "^4.0.2"
brace-expansion@^5.0.8:
version "5.0.9"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
dependencies:
balanced-match "^4.0.2"
braces@^3.0.3:
version "3.0.3"
resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz"
@@ -1429,7 +1555,7 @@ cross-spawn@^7.0.3, cross-spawn@^7.0.6:
shebang-command "^2.0.0"
which "^2.0.1"
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2:
version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -1446,6 +1572,11 @@ dedent@^1.6.0:
resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz"
integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^4.3.1:
version "4.3.1"
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz"
@@ -1548,11 +1679,105 @@ escape-string-regexp@^2.0.0:
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz"
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
escape-string-regexp@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
eslint-scope@^9.1.2:
version "9.1.2"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802"
integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==
dependencies:
"@types/esrecurse" "^4.3.1"
"@types/estree" "^1.0.8"
esrecurse "^4.3.0"
estraverse "^5.2.0"
eslint-visitor-keys@^3.4.3:
version "3.4.3"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
eslint-visitor-keys@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@10.8.1:
version "10.8.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.1.tgz#fb37d514c19b6dd5b2d6b70169fd26fddfa97967"
integrity sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2"
"@eslint/config-array" "^0.23.5"
"@eslint/config-helpers" "^0.7.0"
"@eslint/core" "^1.2.1"
"@eslint/plugin-kit" "^0.7.2"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
"@humanwhocodes/retry" "^0.4.2"
"@types/estree" "^1.0.6"
ajv "^6.14.0"
cross-spawn "^7.0.6"
debug "^4.3.2"
escape-string-regexp "^4.0.0"
eslint-scope "^9.1.2"
eslint-visitor-keys "^5.0.1"
espree "^11.2.0"
esquery "^1.7.0"
esutils "^2.0.2"
fast-deep-equal "^3.1.3"
file-entry-cache "^8.0.0"
find-up "^5.0.0"
glob-parent "^6.0.2"
ignore "^5.2.0"
imurmurhash "^0.1.4"
is-glob "^4.0.0"
json-stable-stringify-without-jsonify "^1.0.1"
minimatch "^10.2.5"
natural-compare "^1.4.0"
optionator "^0.9.3"
espree@^11.2.0:
version "11.2.0"
resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5"
integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==
dependencies:
acorn "^8.16.0"
acorn-jsx "^5.3.2"
eslint-visitor-keys "^5.0.1"
esprima@^4.0.0:
version "4.0.1"
resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
esquery@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
dependencies:
estraverse "^5.1.0"
esrecurse@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
dependencies:
estraverse "^5.2.0"
estraverse@^5.1.0, estraverse@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
ethereum-blockies-base64@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/ethereum-blockies-base64/-/ethereum-blockies-base64-1.0.2.tgz"
@@ -1605,11 +1830,21 @@ expect@30.2.0:
jest-mock "30.2.0"
jest-util "30.2.0"
fast-json-stable-stringify@^2.1.0:
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
fast-levenshtein@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
fb-watchman@^2.0.2:
version "2.0.2"
resolved "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz"
@@ -1617,6 +1852,13 @@ fb-watchman@^2.0.2:
dependencies:
bser "2.1.1"
file-entry-cache@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==
dependencies:
flat-cache "^4.0.0"
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz"
@@ -1632,6 +1874,27 @@ find-up@^4.0.0, find-up@^4.1.0:
locate-path "^5.0.0"
path-exists "^4.0.0"
find-up@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
dependencies:
locate-path "^6.0.0"
path-exists "^4.0.0"
flat-cache@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c"
integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==
dependencies:
flatted "^3.2.9"
keyv "^4.5.4"
flatted@^3.2.9:
version "3.4.4"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6"
integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==
foreground-child@^3.1.0:
version "3.3.1"
resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz"
@@ -1670,6 +1933,13 @@ get-stream@^6.0.0:
resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz"
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
glob-parent@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
dependencies:
is-glob "^4.0.3"
glob@^10.3.10:
version "10.5.0"
resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz"
@@ -1694,6 +1964,11 @@ glob@^7.1.4:
once "^1.3.0"
path-is-absolute "^1.0.0"
globals@17.11.0:
version "17.11.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
graceful-fs@^4.2.11, graceful-fs@^4.2.4:
version "4.2.11"
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"
@@ -1714,6 +1989,11 @@ human-signals@^2.1.0:
resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz"
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
ignore@^5.2.0:
version "5.3.2"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
import-local@^3.2.0:
version "3.2.0"
resolved "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz"
@@ -1760,7 +2040,7 @@ is-generator-fn@^2.1.0:
resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz"
integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==
is-glob@^4.0.3:
is-glob@^4.0.0, is-glob@^4.0.3:
version "4.0.3"
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@@ -2212,21 +2492,51 @@ jsesc@^3.0.2:
resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz"
integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
json-buffer@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
json-parse-even-better-errors@^2.3.0:
version "2.3.1"
resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz"
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
json-schema-traverse@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
json-stable-stringify-without-jsonify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
json5@^2.2.3:
version "2.2.3"
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
keyv@^4.5.4:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
dependencies:
json-buffer "3.0.1"
leven@^3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz"
integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
levn@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
dependencies:
prelude-ls "^1.2.1"
type-check "~0.4.0"
libsodium-sumo@^0.8.0:
version "0.8.2"
resolved "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz"
@@ -2325,6 +2635,13 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
dependencies:
p-locate "^5.0.0"
lru-cache@^10.2.0:
version "10.4.3"
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
@@ -2376,6 +2693,13 @@ mimic-fn@^2.1.0:
resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
minimatch@^10.2.4, minimatch@^10.2.5:
version "10.2.6"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
dependencies:
brace-expansion "^5.0.8"
minimatch@^3.0.4, minimatch@^3.1.1:
version "3.1.3"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz"
@@ -2456,6 +2780,18 @@ onetime@^5.1.2:
dependencies:
mimic-fn "^2.1.0"
optionator@^0.9.3:
version "0.9.4"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==
dependencies:
deep-is "^0.1.3"
fast-levenshtein "^2.0.6"
levn "^0.4.1"
prelude-ls "^1.2.1"
type-check "^0.4.0"
word-wrap "^1.2.5"
p-limit@^2.2.0:
version "2.3.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
@@ -2463,7 +2799,7 @@ p-limit@^2.2.0:
dependencies:
p-try "^2.0.0"
p-limit@^3.1.0:
p-limit@^3.0.2, p-limit@^3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz"
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
@@ -2477,6 +2813,13 @@ p-locate@^4.1.0:
dependencies:
p-limit "^2.2.0"
p-locate@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
dependencies:
p-limit "^3.0.2"
p-try@^2.0.0:
version "2.2.0"
resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
@@ -2562,6 +2905,11 @@ pnglib@0.0.1:
resolved "https://registry.npmjs.org/pnglib/-/pnglib-0.0.1.tgz"
integrity sha512-95ChzOoYLOPIyVmL+Y6X+abKGXUJlvOVLkB1QQkyXl7Uczc6FElUy/x01NS7r2GX6GRezloO/ecCX9h4U9KadA==
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@^3.8.1:
version "3.8.1"
resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz"
@@ -2576,6 +2924,11 @@ pretty-format@30.2.0:
ansi-styles "^5.2.0"
react-is "^18.3.1"
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
pure-rand@^7.0.0:
version "7.0.1"
resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz"
@@ -2822,6 +3175,13 @@ tslib@^2.8.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
dependencies:
prelude-ls "^1.2.1"
type-detect@4.0.8:
version "4.0.8"
resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz"
@@ -2872,6 +3232,13 @@ update-browserslist-db@^1.2.0:
escalade "^3.2.0"
picocolors "^1.1.1"
uri-js@^4.2.2:
version "4.4.1"
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
dependencies:
punycode "^2.1.0"
v8-to-istanbul@^9.0.1:
version "9.3.0"
resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz"
@@ -2900,6 +3267,11 @@ which@^2.0.1:
dependencies:
isexe "^2.0.0"
word-wrap@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"