Compare commits
13
Commits
066842bcec
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
329e64a54d | ||
|
|
88c79e7e05 | ||
|
|
25ead55eac | ||
|
|
a8452a1d46 | ||
|
|
3663f02bf5 | ||
|
|
e590b83df0 | ||
|
|
a0360a7874 | ||
|
|
9776f62f28 | ||
|
|
9bd607b411 | ||
|
|
5d26283cd0 | ||
|
|
d0bbb3d9eb | ||
|
|
35125db6d1 | ||
|
|
eec3e23099 |
@@ -3,6 +3,9 @@ on: [push]
|
|||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Bounds script/cibuild, a cold-cache build included, so a hang frees
|
||||||
|
# the shared runner. README.md "In CI" has the measured times.
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ on: [push]
|
|||||||
jobs:
|
jobs:
|
||||||
e2e-chrome:
|
e2e-chrome:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Bounds the image build, a cold cache included, and both Chrome
|
||||||
|
# programs, so a hung browser frees the shared runner. README.md
|
||||||
|
# "In CI" has the measured times.
|
||||||
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
@@ -42,6 +46,10 @@ jobs:
|
|||||||
|
|
||||||
e2e-firefox:
|
e2e-firefox:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Bounds the image build, a cold cache included, and both Firefox
|
||||||
|
# programs, so a hung browser frees the shared runner. README.md
|
||||||
|
# "In CI" has the measured times.
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
|||||||
@@ -2,4 +2,3 @@ node_modules/
|
|||||||
yarn.lock
|
yarn.lock
|
||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
.claude/
|
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ vendor-blocklist:
|
|||||||
clean:
|
clean:
|
||||||
@rm -rf dist/ release/
|
@rm -rf dist/ release/
|
||||||
|
|
||||||
|
# Run the build make build runs, without the checks that follow it, then run it
|
||||||
|
# again after every change to a file under src/, manifest/ or icons/, until
|
||||||
|
# interrupted. A failed build is reported, may leave dist/ partly written, and
|
||||||
|
# watching carries on. It writes no build receipt, so nothing can verify what it
|
||||||
|
# leaves in dist/: anything handed on comes from make build. A release build
|
||||||
|
# unless AUTISTMASK_DEBUG=1 is exported. A change anywhere else, package.json
|
||||||
|
# and build.js included, starts no build, and a directory created while it runs
|
||||||
|
# is not watched; restart it after either.
|
||||||
dev:
|
dev:
|
||||||
@echo "Building in watch mode..."
|
@echo "Building in watch mode..."
|
||||||
@yarn run build --watch 2>&1
|
@yarn run build --watch 2>&1
|
||||||
|
|||||||
@@ -306,7 +306,15 @@ The Makefile shims to those. It also carries a few targets that have no
|
|||||||
debug build, and keeping its `dist/` on failure (see
|
debug build, and keeping its `dist/` on failure (see
|
||||||
[Debug Builds](#debug-builds))
|
[Debug Builds](#debug-builds))
|
||||||
- `make clean` — remove `dist/` and `release/`
|
- `make clean` — remove `dist/` and `release/`
|
||||||
- `make dev` — build in watch mode
|
- `make dev` — run the build `make build` runs, without the checks that follow
|
||||||
|
it, then run it again after every change to a file under `src/`, `manifest/`
|
||||||
|
or `icons/`, until interrupted. A failed build is reported, may leave `dist/`
|
||||||
|
partly written, and watching carries on. It writes no build receipt, so
|
||||||
|
nothing can verify what it leaves in `dist/` (see
|
||||||
|
[Build Receipts](#build-receipts)): anything handed on comes from
|
||||||
|
`make build`. A release build unless `AUTISTMASK_DEBUG=1` is exported. A
|
||||||
|
change anywhere else, `package.json` and `build.js` included, starts no build,
|
||||||
|
and a directory created while it runs is not watched; restart it after either
|
||||||
|
|
||||||
## End-to-End Tests
|
## End-to-End Tests
|
||||||
|
|
||||||
@@ -342,6 +350,11 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
|
|||||||
offline; unrecognised outbound requests are reported as failures rather than
|
offline; unrecognised outbound requests are reported as failures rather than
|
||||||
silently allowed.
|
silently allowed.
|
||||||
|
|
||||||
|
It also covers the StateRecovery screen, under the shipped CSP: a stored record
|
||||||
|
this build cannot read opens the popup on it, its export text box holds that
|
||||||
|
record exactly as stored, a near-miss confirmation phrase erases nothing, and
|
||||||
|
the exact one erases the record and reloads into Welcome.
|
||||||
|
|
||||||
It also covers the **Settings screen**, which holds the densest run of element
|
It also covers the **Settings screen**, which holds the densest run of element
|
||||||
id lookups in the codebase and where one wrong id leaves the whole popup blank
|
id lookups in the codebase and where one wrong id leaves the whole popup blank
|
||||||
rather than only degrading Settings: that the screen renders populated — the
|
rather than only degrading Settings: that the screen renders populated — the
|
||||||
@@ -371,6 +384,12 @@ reserve while sitting on the same side of the estimate, so swapping the two in
|
|||||||
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
|
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
|
||||||
previously correct by reading only.
|
previously correct by reading only.
|
||||||
|
|
||||||
|
It also covers both ways the wait for a sent transaction's receipt ends on the
|
||||||
|
error screen: lookups that still find no receipt 60 seconds after the broadcast,
|
||||||
|
and six lookups in a row that fail. Each must show its own message, and Done
|
||||||
|
must lead back to the address screen. Both wait in real time, about a minute
|
||||||
|
each.
|
||||||
|
|
||||||
It also covers the **dApp approval round trips** — the one place where the
|
It also covers the **dApp approval round trips** — the one place where the
|
||||||
content script, the inpage provider, the background worker and the approval
|
content script, the inpage provider, the background worker and the approval
|
||||||
popup all have to work together. A local test page is served by the route
|
popup all have to work together. A local test page is served by the route
|
||||||
@@ -464,10 +483,11 @@ Chrome that ever changes this fails the run instead of passing it.
|
|||||||
|
|
||||||
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
|
`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.
|
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
|
||||||
It covers popup load, wallet creation through the UI, the Add Token screen, and
|
It covers popup load, the StateRecovery screen (the same cases as the Chrome
|
||||||
the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
|
suite), wallet creation through the UI, the Add Token screen, and the four dApp
|
||||||
`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
|
round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and
|
||||||
— driven through the real content script, background page and approval windows.
|
a closed approval window rejecting with EIP-1193 4001 — driven through the real
|
||||||
|
content script, background page and approval windows.
|
||||||
|
|
||||||
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
|
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
|
**no npm dependencies at all**: it is built on global `fetch` and
|
||||||
@@ -631,10 +651,20 @@ Nothing in either job can pass vacuously. There is no `continue-on-error` and no
|
|||||||
build fails, and when the browser fails to start; the Chrome harness aborts the
|
build fails, and when the browser fails to start; the Chrome harness aborts the
|
||||||
suite outright if its network interception is not in effect.
|
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
|
Measured on this repo's runner in the green runs of early October 2026, from a
|
||||||
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
|
warm docker cache to a cold one: `check` 49s to 3m37s, `e2e-chrome` 1m44s to
|
||||||
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
|
4m48s, and `e2e-firefox` 31s to 4m07s. A cold cache adds three to four minutes
|
||||||
Firefox and geckodriver downloads the same way.
|
to each job, spent rebuilding its image: reinstalling dependencies and, for
|
||||||
|
`e2e-firefox`, installing Firefox, geckodriver and their system libraries. Those
|
||||||
|
`e2e-chrome` runs predate the cases that wait in real time for a receipt to end
|
||||||
|
in error. `make test-e2e` now takes 3m51s locally with its image cached, so a
|
||||||
|
cold `e2e-chrome` run comes to about seven minutes.
|
||||||
|
|
||||||
|
Every job has a `timeout-minutes` cap, so a hung build or browser ends the job
|
||||||
|
instead of holding the shared runner: `check` 10 minutes, `e2e-firefox` 15 and
|
||||||
|
`e2e-chrome` 20, each over two and a half times the job's slowest cold run. A
|
||||||
|
job that reaches its cap has hung; read it as a hang, not as a slow run to
|
||||||
|
retry.
|
||||||
|
|
||||||
### Element id guard (part of `make check`)
|
### Element id guard (part of `make check`)
|
||||||
|
|
||||||
@@ -800,13 +830,15 @@ discoverable.
|
|||||||
on critical screens and when space is available to allow users to disambiguate
|
on critical screens and when space is available to allow users to disambiguate
|
||||||
addresses visually, as a security feature.
|
addresses visually, as a security feature.
|
||||||
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
|
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
|
||||||
styling. Tailwind is configured with a minimal monochrome palette. This keeps
|
styling, and no `style="..."` attributes, which the
|
||||||
the styling co-located with the markup and eliminates CSS file management. The
|
[Content Security Policy](#content-security-policy) refuses. Tailwind is
|
||||||
handful of classes in `styles/main.css` are not styling: `.copy-flash-*`
|
configured with a minimal monochrome palette. This keeps the styling
|
||||||
carries the copy feedback animation, and `.am-address` carries the rule that
|
co-located with the markup and eliminates CSS file management. The handful of
|
||||||
an address never wraps. Both are invariants that hold in every place they
|
classes in `styles/main.css` are not styling: `.copy-flash-*` carries the copy
|
||||||
appear, and spelling either out as repeated utilities is how one of those
|
feedback animation, and `.am-address` carries the rule that an address never
|
||||||
places drifts away from the rest.
|
wraps. Both are invariants that hold in every place they appear, and spelling
|
||||||
|
either out as repeated utilities is how one of those places drifts away from
|
||||||
|
the rest.
|
||||||
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
|
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
|
||||||
enough that vanilla JS with simple view switching is sufficient. A framework
|
enough that vanilla JS with simple view switching is sufficient. A framework
|
||||||
would add bundle size, build complexity, and attack surface for no benefit at
|
would add bundle size, build complexity, and attack surface for no benefit at
|
||||||
@@ -2174,8 +2206,8 @@ Dev dependencies (not shipped in extension):
|
|||||||
| Package | Version | License | Purpose |
|
| Package | Version | License | Purpose |
|
||||||
| ------------------ | ------- | ------- | ------------------------- |
|
| ------------------ | ------- | ------- | ------------------------- |
|
||||||
| `esbuild` | 0.27.3 | MIT | JS bundler (inlines deps) |
|
| `esbuild` | 0.27.3 | MIT | JS bundler (inlines deps) |
|
||||||
| `tailwindcss` | 4.2.1 | MIT | CSS compilation |
|
| `tailwindcss` | 4.3.1 | MIT | CSS compilation |
|
||||||
| `@tailwindcss/cli` | 4.2.1 | MIT | Tailwind CLI |
|
| `@tailwindcss/cli` | 4.3.1 | MIT | Tailwind CLI |
|
||||||
| `jest` | 30.2.0 | MIT | Test runner |
|
| `jest` | 30.2.0 | MIT | Test runner |
|
||||||
| `prettier` | 3.8.1 | MIT | Code formatter |
|
| `prettier` | 3.8.1 | MIT | Code formatter |
|
||||||
|
|
||||||
@@ -2199,7 +2231,7 @@ a bare string in `manifest/firefox.json` (MV2):
|
|||||||
|
|
||||||
```
|
```
|
||||||
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self';
|
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self';
|
||||||
style-src 'self' 'unsafe-inline'; img-src 'self' data:;
|
style-src 'self'; img-src 'self' data:;
|
||||||
connect-src 'self' https: http:; frame-src 'none'; form-action 'none';
|
connect-src 'self' https: http:; frame-src 'none'; form-action 'none';
|
||||||
base-uri 'none'
|
base-uri 'none'
|
||||||
```
|
```
|
||||||
@@ -2211,15 +2243,17 @@ wallet's own UI. Escaping is the primary fix for that (see
|
|||||||
`src/shared/html.js`); this is the second line, so an escape that does slip
|
`src/shared/html.js`); this is the second line, so an escape that does slip
|
||||||
cannot reach the network.
|
cannot reach the network.
|
||||||
|
|
||||||
Four directives are looser than `'self'`, each for a reason that does not
|
`style-src 'self'` admits the stylesheet and nothing inline: both browsers
|
||||||
|
refuse a `style="..."` attribute and a `<style>` block. So the popup's markup,
|
||||||
|
in `src/popup/index.html` and in the HTML the view helpers build, carries
|
||||||
|
Tailwind classes and never a `style` attribute. Script that sets `element.style`
|
||||||
|
is not affected; that is how the views show and hide their error lines. An
|
||||||
|
inline style that slips in anyway is refused with a console error, which fails
|
||||||
|
both end-to-end suites.
|
||||||
|
|
||||||
|
These directives differ from a plain `'self'`, each for a reason that does not
|
||||||
generalise:
|
generalise:
|
||||||
|
|
||||||
- `style-src 'unsafe-inline'` — `src/popup/index.html` and the view helpers set
|
|
||||||
presentation through `style="..."` attributes, which CSP blocks without this.
|
|
||||||
Chrome enforces `style-src` on attributes, not only on `<style>` blocks, and
|
|
||||||
Firefox has never implemented `style-src-attr`, so there is no narrower
|
|
||||||
spelling that works on both targets. It permits inline **style**; script stays
|
|
||||||
under `script-src`, which does not allow `'unsafe-inline'`.
|
|
||||||
- `img-src data:` — identicons are generated in the popup by
|
- `img-src data:` — identicons are generated in the popup by
|
||||||
`ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs.
|
`ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs.
|
||||||
- `connect-src https: http:` — the RPC endpoint is user-configurable and a local
|
- `connect-src https: http:` — the RPC endpoint is user-configurable and a local
|
||||||
|
|||||||
@@ -45,6 +45,134 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-06: Reloading or closing the popup mid-refresh no longer logs the
|
||||||
|
requests that cancels as failures
|
||||||
|
([#218](https://git.eeqj.de/sneak/AutistMask/issues/218)). Chrome cancels a
|
||||||
|
closing page's open requests just after `pagehide`, and in the page a
|
||||||
|
cancelled `fetch()` fails with the same "Failed to fetch" as a server that
|
||||||
|
cannot be reached, so the home screen's transaction list and the balance
|
||||||
|
refresh logged an error for each, and the e2e suite failed on them. The popup
|
||||||
|
now aborts an `AbortController` on `pagehide`, and those two check its signal
|
||||||
|
before reporting a failure. New e2e tests reload the popup with Blockscout
|
||||||
|
held and require nothing logged, and fail the transaction list for real and
|
||||||
|
require the failure reported; `tests/balanceRefreshCancelled.test.js` covers
|
||||||
|
the balance refresh. The address and token screens and the address scan after
|
||||||
|
a new wallet still log a cancelled request
|
||||||
|
([#475](https://git.eeqj.de/sneak/AutistMask/issues/475)).
|
||||||
|
|
||||||
|
- 2026-10-05: `make build` no longer prints the Node `DEP0205`
|
||||||
|
`module.register()` deprecation warning
|
||||||
|
([#355](https://git.eeqj.de/sneak/AutistMask/issues/355)). The call came from
|
||||||
|
`@tailwindcss/node`, which the Tailwind CLI loads; `tailwindcss` and
|
||||||
|
`@tailwindcss/cli` are now pinned at 4.3.1, the first release that uses
|
||||||
|
`module.registerHooks()` where Node has it. The compiled CSS computes to the
|
||||||
|
same values; it drops the unused `.start` and `.end` rules and writes
|
||||||
|
`calc(var(--spacing) * 1)` as `var(--spacing)` and `calc(var(--spacing) * 0)`
|
||||||
|
as `0`.
|
||||||
|
|
||||||
|
- 2026-10-05: `make dev` watches
|
||||||
|
([#332](https://git.eeqj.de/sneak/AutistMask/issues/332)). It used to pass
|
||||||
|
`--watch` to a `build.js` that read no arguments, so it built once and exited.
|
||||||
|
`build.js --watch` now builds, then builds again after every change to a file
|
||||||
|
under `src/`, `manifest/` or `icons/`; any other argument fails. It watches
|
||||||
|
each directory rather than using Node's recursive watch, which on Linux stops
|
||||||
|
seeing a file an editor saves by renaming a new copy over it. It writes no
|
||||||
|
build receipt, so nothing can verify what it builds; `make build` remains the
|
||||||
|
way to produce a `dist/` to hand on. `tests/buildWatch.test.js` covers the
|
||||||
|
watch loop against a temp directory.
|
||||||
|
|
||||||
|
- 2026-10-05: Every CI job has a `timeout-minutes` cap
|
||||||
|
([#294](https://git.eeqj.de/sneak/AutistMask/issues/294)): `check` 10 minutes,
|
||||||
|
`e2e-firefox` 15 and `e2e-chrome` 20, each over two and a half times the job's
|
||||||
|
slowest cold-cache run. A hung build or browser now ends its job instead of
|
||||||
|
holding the shared runner for hours.
|
||||||
|
|
||||||
|
- 2026-10-05: `PROXY_METHODS` in `src/background/index.js` no longer lists
|
||||||
|
`eth_chainId` and `net_version`
|
||||||
|
([#326](https://git.eeqj.de/sneak/AutistMask/issues/326)). `handleRpc` answers
|
||||||
|
both itself before its proxy branch, so the list named two methods that are
|
||||||
|
never sent to the RPC endpoint. No other entry is answered earlier.
|
||||||
|
`tests/proxyMethods.test.js` sends every listed method from a page and fails
|
||||||
|
on any that does not reach the RPC endpoint.
|
||||||
|
|
||||||
|
- 2026-10-05: The popup's Content Security Policy no longer allows inline style
|
||||||
|
([#328](https://git.eeqj.de/sneak/AutistMask/issues/328)): `style-src` is
|
||||||
|
`'self'` in both manifests, pinned in `tests/manifest.test.js`. The 42
|
||||||
|
`style="..."` attributes in `src/popup/index.html` and in the markup the view
|
||||||
|
helpers build are now Tailwind classes, each computing to the value it
|
||||||
|
replaced. The 16 address dot colours are written out as whole classes, because
|
||||||
|
Tailwind builds only the classes it finds in the source. The Settings debug
|
||||||
|
well is shown and hidden with the `hidden` class, since clearing an inline
|
||||||
|
`display` no longer uncovers it. Script that sets `element.style` is
|
||||||
|
unaffected.
|
||||||
|
|
||||||
|
- 2026-10-05: `.prettierignore` no longer lists an AI vendor's tool directory
|
||||||
|
([#363](https://git.eeqj.de/sneak/AutistMask/issues/363)). The directory is
|
||||||
|
not tracked, so the line ignored nothing.
|
||||||
|
|
||||||
|
- 2026-10-05: The Chrome end-to-end suite drives both ways the wait for a
|
||||||
|
transaction's receipt ends on the error screen
|
||||||
|
([#315](https://git.eeqj.de/sneak/AutistMask/issues/315)): lookups that still
|
||||||
|
find no receipt 60 seconds after the broadcast end it with the timeout
|
||||||
|
message, and six lookups that fail in a row end it with the message naming the
|
||||||
|
unreachable network. Done then returns to the address screen. Both cases wait
|
||||||
|
in real time, about a minute each. Playwright's clock would apply to every
|
||||||
|
later test in the run and cannot be removed, and moving the stored broadcast
|
||||||
|
time back can be undone by the save the popup makes every ten seconds.
|
||||||
|
|
||||||
|
- 2026-10-05: The Chrome end-to-end suite covers the last of the
|
||||||
|
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
|
||||||
|
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) items
|
||||||
|
([#295](https://git.eeqj.de/sneak/AutistMask/issues/295)): a token added on
|
||||||
|
Add Token by its contract address is listed on the address screen;
|
||||||
|
TransactionDetail opened from the token screen leaves that screen on the
|
||||||
|
persisted navigation stack, and Back returns to it; and the token contract row
|
||||||
|
links to the explorer's token page, read off the link rather than followed.
|
||||||
|
The network stub answers `symbol()` and `name()` for the stub token, which
|
||||||
|
adding it reads.
|
||||||
|
|
||||||
|
- 2026-10-05: Each control that leads to a signature or to the private key has a
|
||||||
|
test that it refuses a defective wallet before asking for a password
|
||||||
|
([#254](https://git.eeqj.de/sneak/AutistMask/issues/254)): Send on the main,
|
||||||
|
address and token screens, Export Private Key, and both approval screens, as
|
||||||
|
drawn and as clicked. Send on the confirmation screen refuses it too now,
|
||||||
|
because the popup reopens onto that screen from a saved view. The comments
|
||||||
|
that said the wallet's key cannot be derived now say that
|
||||||
|
`getSignerForAddress` refuses it, and the module comment in
|
||||||
|
`src/shared/walletDefects.js` names both earlier import paths.
|
||||||
|
|
||||||
|
- 2026-10-05: The Chrome end-to-end suite drives the private key export screen
|
||||||
|
as it drives the recovery phrase screen
|
||||||
|
([#253](https://git.eeqj.de/sneak/AutistMask/issues/253)): the correct
|
||||||
|
password shows the key, leaving by the settings gear empties the screen, and
|
||||||
|
leaving while the password is still being checked never puts the key on it.
|
||||||
|
The cases use the imported key wallet rather than the HD one. Leaving drops
|
||||||
|
the address the screen was showing, and an HD wallet's key cannot be derived
|
||||||
|
without it, so on an HD wallet a late decrypt fails by itself and would never
|
||||||
|
exercise the check that discards it. The screen cannot yet be opened twice in
|
||||||
|
one popup session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)),
|
||||||
|
so the cases reopen the popup before the second open.
|
||||||
|
|
||||||
|
- 2026-10-05: The StateRecovery screen is driven in a real browser under the
|
||||||
|
shipped CSP, in both end-to-end suites
|
||||||
|
([#361](https://git.eeqj.de/sneak/AutistMask/issues/361)). A stored record
|
||||||
|
this build cannot read opens the popup on it; its export text box holds the
|
||||||
|
record exactly as stored; a near-miss confirmation phrase erases nothing; and
|
||||||
|
the exact phrase erases the record and reloads into Welcome. The cases run
|
||||||
|
before any wallet exists: with no wallet nothing saves on a timer, so no save
|
||||||
|
can write a good record over the unreadable one, and the erase leaves the
|
||||||
|
popup on Welcome for wallet creation.
|
||||||
|
|
||||||
|
- 2026-10-05: Escaping in the popup's views follows its own rule with no
|
||||||
|
exceptions ([#329](https://git.eeqj.de/sneak/AutistMask/issues/329)). The
|
||||||
|
decimals and holder count on a token's screen, and every USD figure (the ETH
|
||||||
|
price, each total and each balance row's value), went into `innerHTML`
|
||||||
|
unescaped; they are escaped now. None could carry markup, but `formatUsd()`
|
||||||
|
writes a value under a cent as `< $0.01`. `displaySymbol()` counts a symbol in
|
||||||
|
code points rather than UTF-16 units, so a cut never splits an emoji into a
|
||||||
|
half that renders as U+FFFD. `explorerLink()`, also named in the issue, was
|
||||||
|
already removed by [#168](https://git.eeqj.de/sneak/AutistMask/issues/168).
|
||||||
|
|
||||||
- 2026-10-05: A Chrome end-to-end test that fails no longer takes later tests
|
- 2026-10-05: A Chrome end-to-end test that fails no longer takes later tests
|
||||||
down with it ([#318](https://git.eeqj.de/sneak/AutistMask/issues/318)). Each
|
down with it ([#318](https://git.eeqj.de/sneak/AutistMask/issues/318)). Each
|
||||||
test that turns a fixture switch on for itself alone (a held or failing gas
|
test that turns a fixture switch on for itself alone (a held or failing gas
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ const DIST_CHROME = path.join(DIST, "chrome");
|
|||||||
const DIST_FIREFOX = path.join(DIST, "firefox");
|
const DIST_FIREFOX = path.join(DIST, "firefox");
|
||||||
const SRC = path.join(__dirname, "src");
|
const SRC = path.join(__dirname, "src");
|
||||||
|
|
||||||
|
// What `make dev` watches: the directories whose files build() bundles,
|
||||||
|
// compiles or copies. A change anywhere else, package.json and build.js
|
||||||
|
// included, starts no build; restart make dev after one.
|
||||||
|
const WATCHED_DIRS = [
|
||||||
|
SRC,
|
||||||
|
path.join(__dirname, "manifest"),
|
||||||
|
path.join(__dirname, "icons"),
|
||||||
|
];
|
||||||
|
|
||||||
// The module whose compiled DEBUG state script/verify-build asserts. Which
|
// The module whose compiled DEBUG state script/verify-build asserts. Which
|
||||||
// bundles contain it is derived from esbuild's own dependency graph rather
|
// bundles contain it is derived from esbuild's own dependency graph rather
|
||||||
// than from a hardcoded list, so it tracks the bundle layout instead of
|
// than from a hardcoded list, so it tracks the bundle layout instead of
|
||||||
@@ -441,6 +450,10 @@ function getBuildInfo() {
|
|||||||
async function build() {
|
async function build() {
|
||||||
console.log("Building AutistMask extension...");
|
console.log("Building AutistMask extension...");
|
||||||
|
|
||||||
|
// Under make dev this runs once per rebuild in the same process, and each
|
||||||
|
// build accounts only for what it emits itself.
|
||||||
|
emittedFiles.length = 0;
|
||||||
|
|
||||||
const receiptPath = receiptTarget();
|
const receiptPath = receiptTarget();
|
||||||
if (!receiptPath) {
|
if (!receiptPath) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -597,27 +610,94 @@ async function build() {
|
|||||||
console.log("Build complete: dist/chrome/ and dist/firefox/");
|
console.log("Build complete: dist/chrome/ and dist/firefox/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// make dev: build, then build again after every change under `dirs`, until
|
||||||
|
// interrupted. A failed build is reported and watching carries on, so a
|
||||||
|
// half-finished edit does not end it. A change that arrives while a build is
|
||||||
|
// running is not lost: it causes one more build as soon as that one finishes.
|
||||||
|
// A directory created after this starts is not watched until a restart.
|
||||||
|
//
|
||||||
|
// make dev sets no AUTISTMASK_BUILD_RECEIPT, so these builds write no receipt
|
||||||
|
// and nothing can verify what they leave in dist/.
|
||||||
|
//
|
||||||
|
// `rebuild` is build() everywhere but tests/buildWatch.test.js, which also
|
||||||
|
// closes the returned watchers.
|
||||||
|
function watch(dirs, rebuild) {
|
||||||
|
let building = false;
|
||||||
|
let changedAgain = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (building) {
|
||||||
|
changedAgain = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
building = true;
|
||||||
|
do {
|
||||||
|
// Let the rest of one save's events arrive first, so that one
|
||||||
|
// save is one build.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
changedAgain = false;
|
||||||
|
try {
|
||||||
|
await rebuild();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Build failed: ${err && err.message ? err.message : err}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} while (changedAgain);
|
||||||
|
building = false;
|
||||||
|
console.log("Watching for changes (Ctrl-C to stop)...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// One watcher per directory rather than fs.watch's recursive option: on
|
||||||
|
// Linux, Node's recursive watch watches each file, and stops seeing one
|
||||||
|
// that an editor saves by renaming a new copy over it. A directory's
|
||||||
|
// watcher reports every change to the files in it, however they are saved.
|
||||||
|
const subdirectories = dirs.flatMap((dir) =>
|
||||||
|
fs
|
||||||
|
.readdirSync(dir, { recursive: true, withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => path.join(entry.parentPath, entry.name)),
|
||||||
|
);
|
||||||
|
const watchers = [...dirs, ...subdirectories].map((dir) =>
|
||||||
|
fs.watch(dir, run),
|
||||||
|
);
|
||||||
|
run();
|
||||||
|
return watchers;
|
||||||
|
}
|
||||||
|
|
||||||
// Run only as a program. Required as a module — which is how
|
// Run only as a program. Required as a module — which is how
|
||||||
// tests/buildForbiddenInputs.test.js reaches the checks below — this file
|
// tests/buildForbiddenInputs.test.js and tests/buildWatch.test.js reach the
|
||||||
// builds nothing and writes nothing.
|
// functions below — this file builds nothing and writes nothing.
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
// An argument this file does not know fails rather than being ignored.
|
||||||
|
if (args.length > 1 || (args.length === 1 && args[0] !== "--watch")) {
|
||||||
|
console.error("usage: node build.js [--watch]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (args[0] === "--watch") {
|
||||||
|
watch(WATCHED_DIRS, build);
|
||||||
|
} else {
|
||||||
build().catch((err) => {
|
build().catch((err) => {
|
||||||
console.error(
|
console.error(
|
||||||
`Build failed: ${err && err.message ? err.message : err}`,
|
`Build failed: ${err && err.message ? err.message : err}`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exported for tests/buildForbiddenInputs.test.js only. The prohibition these
|
// Exported for tests only: watch() for tests/buildWatch.test.js, the rest for
|
||||||
// three functions enforce is the guarantee behind
|
// tests/buildForbiddenInputs.test.js. The prohibition those enforce is the
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/324, and `make check` does not
|
// guarantee behind https://git.eeqj.de/sneak/AutistMask/issues/324, and
|
||||||
// run `make build` — so they are unit tested against synthetic metafiles
|
// `make check` does not run `make build` — so they are unit tested against
|
||||||
// rather than being exercised only by CI, where "it ran" is not "it works".
|
// synthetic metafiles rather than being exercised only by CI, where "it ran"
|
||||||
|
// is not "it works".
|
||||||
module.exports = {
|
module.exports = {
|
||||||
importChain,
|
importChain,
|
||||||
newForbiddenRecord,
|
newForbiddenRecord,
|
||||||
recordBundledInputs,
|
recordBundledInputs,
|
||||||
assertNoForbiddenInputs,
|
assertNoForbiddenInputs,
|
||||||
assertForbiddenTableCovered,
|
assertForbiddenTableCovered,
|
||||||
|
watch,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"permissions": ["storage", "activeTab", "alarms"],
|
"permissions": ["storage", "activeTab", "alarms"],
|
||||||
"host_permissions": ["<all_urls>"],
|
"host_permissions": ["<all_urls>"],
|
||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
|
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
|
||||||
},
|
},
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Minimal Ethereum wallet for Firefox",
|
"description": "Minimal Ethereum wallet for Firefox",
|
||||||
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
||||||
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
|
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
"32": "icons/icon32.png",
|
"32": "icons/icon32.png",
|
||||||
|
|||||||
+2
-2
@@ -15,14 +15,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@tailwindcss/cli": "^4.2.1",
|
"@tailwindcss/cli": "4.3.1",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"eslint": "10.8.1",
|
"eslint": "10.8.1",
|
||||||
"globals": "17.11.0",
|
"globals": "17.11.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"playwright-core": "1.56.0",
|
"playwright-core": "1.56.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^4.2.1"
|
"tailwindcss": "4.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ethereum-blockies-base64": "^1.0.2",
|
"ethereum-blockies-base64": "^1.0.2",
|
||||||
|
|||||||
@@ -741,11 +741,12 @@ async function handleConnectionRequest(origin) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Methods that are safe to proxy directly to the RPC node
|
// Methods that are safe to proxy directly to the RPC node. A method handleRpc
|
||||||
|
// answers before its proxy branch does not belong here: it would never reach
|
||||||
|
// the node. tests/proxyMethods.test.js sends every one of these.
|
||||||
const PROXY_METHODS = [
|
const PROXY_METHODS = [
|
||||||
"eth_blockNumber",
|
"eth_blockNumber",
|
||||||
"eth_call",
|
"eth_call",
|
||||||
"eth_chainId",
|
|
||||||
"eth_estimateGas",
|
"eth_estimateGas",
|
||||||
"eth_gasPrice",
|
"eth_gasPrice",
|
||||||
"eth_getBalance",
|
"eth_getBalance",
|
||||||
@@ -759,7 +760,6 @@ const PROXY_METHODS = [
|
|||||||
"eth_getTransactionReceipt",
|
"eth_getTransactionReceipt",
|
||||||
"eth_maxPriorityFeePerGas",
|
"eth_maxPriorityFeePerGas",
|
||||||
"eth_sendRawTransaction",
|
"eth_sendRawTransaction",
|
||||||
"net_version",
|
|
||||||
"web3_clientVersion",
|
"web3_clientVersion",
|
||||||
"eth_feeHistory",
|
"eth_feeHistory",
|
||||||
"eth_getBlockTransactionCountByHash",
|
"eth_getBlockTransactionCountByHash",
|
||||||
@@ -1802,3 +1802,5 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
module.exports = { PROXY_METHODS };
|
||||||
|
|||||||
+32
-83
@@ -110,8 +110,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="add-wallet-phrase-warning"
|
id="add-wallet-phrase-warning"
|
||||||
class="text-xs mb-2 border border-border border-dashed p-2"
|
class="text-xs mb-2 border border-border border-dashed p-2 invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
>
|
>
|
||||||
Write these words down and keep them safe. Anyone with
|
Write these words down and keep them safe. Anyone with
|
||||||
them can take your funds; if you lose them, your wallet
|
them can take your funds; if you lose them, your wallet
|
||||||
@@ -262,10 +261,7 @@
|
|||||||
|
|
||||||
<!-- recent transactions across all addresses -->
|
<!-- recent transactions across all addresses -->
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div class="font-bold bg-section py-1 px-2 -mx-2">
|
||||||
class="font-bold bg-section py-1 px-2"
|
|
||||||
style="margin-left: -0.5rem; margin-right: -0.5rem"
|
|
||||||
>
|
|
||||||
Recent Transactions
|
Recent Transactions
|
||||||
</div>
|
</div>
|
||||||
<div id="home-tx-list">
|
<div id="home-tx-list">
|
||||||
@@ -273,7 +269,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-1" style="margin: 0 -0.5rem"> </div>
|
<div class="py-1 -mx-2"> </div>
|
||||||
|
|
||||||
<div class="text-xs text-muted">
|
<div class="text-xs text-muted">
|
||||||
<span
|
<span
|
||||||
@@ -409,8 +405,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="export-privkey-flash"
|
id="export-privkey-flash"
|
||||||
class="text-xs mb-2 min-h-[1.25rem]"
|
class="text-xs mb-2 min-h-[1.25rem] invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<div id="export-privkey-password-section" class="mb-2">
|
<div id="export-privkey-password-section" class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
@@ -542,8 +537,7 @@
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
id="send-to-error"
|
id="send-to-error"
|
||||||
class="text-xs"
|
class="text-xs min-h-[1.25rem] text-[#cc0000]"
|
||||||
style="min-height: 1.25rem; color: #cc0000"
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@@ -619,7 +613,7 @@
|
|||||||
<div class="text-xs text-muted mb-1">Your balance</div>
|
<div class="text-xs text-muted mb-1">Your balance</div>
|
||||||
<div id="confirm-balance" class="text-xs"></div>
|
<div id="confirm-balance" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
|
<div id="confirm-fee" class="mb-3 invisible">
|
||||||
<div class="text-xs text-muted mb-1">Network fee</div>
|
<div class="text-xs text-muted mb-1">Network fee</div>
|
||||||
<div id="confirm-fee-amount" class="text-xs"></div>
|
<div id="confirm-fee-amount" class="text-xs"></div>
|
||||||
<!-- Holds its one line of space from the first paint, so
|
<!-- Holds its one line of space from the first paint, so
|
||||||
@@ -627,22 +621,13 @@
|
|||||||
nothing. The placeholder is never seen. -->
|
nothing. The placeholder is never seen. -->
|
||||||
<div
|
<div
|
||||||
id="confirm-fee-reserve"
|
id="confirm-fee-reserve"
|
||||||
class="text-xs text-muted"
|
class="text-xs text-muted invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
>
|
>
|
||||||
reserve pending
|
reserve pending
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div id="confirm-warnings" class="mb-2 invisible"></div>
|
||||||
id="confirm-warnings"
|
<div id="confirm-recipient-warning" class="mb-2 invisible">
|
||||||
class="mb-2"
|
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
id="confirm-recipient-warning"
|
|
||||||
class="mb-2"
|
|
||||||
style="visibility: hidden"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||||
>
|
>
|
||||||
@@ -655,14 +640,9 @@
|
|||||||
in confirmTx.js sets it. -->
|
in confirmTx.js sets it. -->
|
||||||
<div
|
<div
|
||||||
id="confirm-contract-warning"
|
id="confirm-contract-warning"
|
||||||
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div id="confirm-burn-warning" class="mb-2 invisible">
|
||||||
id="confirm-burn-warning"
|
|
||||||
class="mb-2"
|
|
||||||
style="visibility: hidden"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||||
>
|
>
|
||||||
@@ -670,11 +650,7 @@
|
|||||||
here are permanently destroyed and cannot be recovered.
|
here are permanently destroyed and cannot be recovered.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div id="confirm-etherscan-warning" class="mb-2 invisible">
|
||||||
id="confirm-etherscan-warning"
|
|
||||||
class="mb-2"
|
|
||||||
style="visibility: hidden"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||||
>
|
>
|
||||||
@@ -684,13 +660,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="confirm-errors"
|
id="confirm-errors"
|
||||||
class="mb-2 border border-border border-dashed p-2"
|
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
|
||||||
style="visibility: hidden; min-height: 1.25rem"
|
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
id="confirm-amount-fee-error"
|
id="confirm-amount-fee-error"
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
>
|
>
|
||||||
Your balance does not cover this amount plus the network
|
Your balance does not cover this amount plus the network
|
||||||
fee. Please go back and send a smaller amount.
|
fee. Please go back and send a smaller amount.
|
||||||
@@ -699,15 +673,13 @@
|
|||||||
in confirmTx.js sets it. -->
|
in confirmTx.js sets it. -->
|
||||||
<div
|
<div
|
||||||
id="confirm-gas-error"
|
id="confirm-gas-error"
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<!-- Its sentence names why the fee could not be estimated,
|
<!-- Its sentence names why the fee could not be estimated,
|
||||||
so show() in confirmTx.js sets it. -->
|
so show() in confirmTx.js sets it. -->
|
||||||
<div
|
<div
|
||||||
id="confirm-fee-unknown-error"
|
id="confirm-fee-unknown-error"
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
@@ -719,8 +691,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="confirm-tx-password-error"
|
id="confirm-tx-password-error"
|
||||||
class="text-xs mb-2 min-h-[1.25rem]"
|
class="text-xs mb-2 min-h-[1.25rem] invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-confirm-send"
|
id="btn-confirm-send"
|
||||||
@@ -835,8 +806,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
id="receive-erc20-warning"
|
id="receive-erc20-warning"
|
||||||
class="text-xs border border-border border-dashed p-2 mt-3"
|
class="text-xs border border-border border-dashed p-2 mt-3 invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -864,8 +834,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="add-token-info"
|
id="add-token-info"
|
||||||
class="text-xs text-muted mb-2 min-h-[1.25rem]"
|
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs text-muted"
|
<label class="block mb-1 text-xs text-muted"
|
||||||
@@ -1051,8 +1020,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
id="settings-dust-threshold"
|
id="settings-dust-threshold"
|
||||||
class="border border-border p-1 text-xs bg-bg text-fg"
|
class="border border-border p-1 text-xs bg-bg text-fg w-[10ch]"
|
||||||
style="width: 10ch"
|
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-muted">gwei</span>
|
<span class="text-xs text-muted">gwei</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1129,8 +1097,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="settings-debug-well"
|
id="settings-debug-well"
|
||||||
class="bg-well p-3 mx-1 mb-3"
|
class="bg-well p-3 mx-1 mb-3 hidden"
|
||||||
style="display: none"
|
|
||||||
>
|
>
|
||||||
<h3 class="font-bold mb-1">Debug</h3>
|
<h3 class="font-bold mb-1">Debug</h3>
|
||||||
<label
|
<label
|
||||||
@@ -1158,8 +1125,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="delete-wallet-flash"
|
id="delete-wallet-flash"
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
@@ -1232,8 +1198,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="delete-wallet-lost-flash"
|
id="delete-wallet-lost-flash"
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-delete-wallet-lost-confirm"
|
id="btn-delete-wallet-lost-confirm"
|
||||||
@@ -1288,8 +1253,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="delete-address-flash"
|
id="delete-address-flash"
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-delete-address-confirm"
|
id="btn-delete-address-confirm"
|
||||||
@@ -1318,8 +1282,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="show-phrase-flash"
|
id="show-phrase-flash"
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<div id="show-phrase-password-section" class="mb-2">
|
<div id="show-phrase-password-section" class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
@@ -1401,8 +1364,7 @@
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
id="settings-addtoken-info"
|
id="settings-addtoken-info"
|
||||||
class="text-xs text-muted mt-1 min-h-[1.25rem]"
|
class="text-xs text-muted mt-1 min-h-[1.25rem] invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-settings-addtoken-manual"
|
id="btn-settings-addtoken-manual"
|
||||||
@@ -1635,8 +1597,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="approve-tx-error"
|
id="approve-tx-error"
|
||||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
|
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<button
|
<button
|
||||||
@@ -1672,15 +1633,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="approve-sign-danger-warning"
|
id="approve-sign-danger-warning"
|
||||||
class="mb-3 p-2 text-xs font-bold"
|
class="mb-3 p-2 text-xs font-bold invisible min-h-[1.25rem] bg-[#fee2e2] text-[#991b1b] border-2 border-[#dc2626] rounded-[6px]"
|
||||||
style="
|
|
||||||
visibility: hidden;
|
|
||||||
min-height: 1.25rem;
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
border: 2px solid #dc2626;
|
|
||||||
border-radius: 6px;
|
|
||||||
"
|
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -1697,8 +1650,7 @@
|
|||||||
<div class="text-xs text-muted mb-1">Message</div>
|
<div class="text-xs text-muted mb-1">Message</div>
|
||||||
<div
|
<div
|
||||||
id="approve-sign-message"
|
id="approve-sign-message"
|
||||||
class="text-xs break-all"
|
class="text-xs break-all max-h-48 overflow-y-auto"
|
||||||
style="max-height: 12rem; overflow-y: auto"
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1706,8 +1658,7 @@
|
|||||||
<div class="text-xs text-muted mb-1">Raw data</div>
|
<div class="text-xs text-muted mb-1">Raw data</div>
|
||||||
<div
|
<div
|
||||||
id="approve-sign-hex"
|
id="approve-sign-hex"
|
||||||
class="text-xs break-all"
|
class="text-xs break-all max-h-24 overflow-y-auto"
|
||||||
style="max-height: 6rem; overflow-y: auto"
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1721,8 +1672,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="approve-sign-error"
|
id="approve-sign-error"
|
||||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
|
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<button
|
<button
|
||||||
@@ -1846,8 +1796,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="state-recovery-flash"
|
id="state-recovery-flash"
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
||||||
style="visibility: hidden"
|
|
||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-state-recovery-reset"
|
id="btn-state-recovery-reset"
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ function renderWalletList() {
|
|||||||
home.render(ctx);
|
home.render(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aborted when the popup page goes away, closed or reloaded. Chrome then
|
||||||
|
// cancels the requests the page still has open, and a cancelled fetch() fails
|
||||||
|
// with the same "Failed to fetch" as a server that cannot be reached. pagehide
|
||||||
|
// fires first, so code that reports a failed request checks this and stays
|
||||||
|
// silent about one the page's own closing cancelled.
|
||||||
|
const pageClosed = new AbortController();
|
||||||
|
window.addEventListener("pagehide", () => pageClosed.abort());
|
||||||
|
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
|
|
||||||
// The ten-second refresh init() starts, stopped when the popup moves to the
|
// The ten-second refresh init() starts, stopped when the popup moves to the
|
||||||
@@ -66,6 +74,7 @@ async function doRefreshAndRender() {
|
|||||||
state.blockscoutUrl,
|
state.blockscoutUrl,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
|
pageClosed.signal,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
state.lastBalanceRefresh = Date.now();
|
state.lastBalanceRefresh = Date.now();
|
||||||
@@ -87,6 +96,7 @@ async function doRefreshAndRender() {
|
|||||||
const ctx = {
|
const ctx = {
|
||||||
renderWalletList,
|
renderWalletList,
|
||||||
doRefreshAndRender,
|
doRefreshAndRender,
|
||||||
|
pageClosed: pageClosed.signal,
|
||||||
showAddWalletView: () => {
|
showAddWalletView: () => {
|
||||||
pushCurrentView();
|
pushCurrentView();
|
||||||
addWallet.show();
|
addWallet.show();
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ const { walletDefect } = require("../../shared/walletDefects");
|
|||||||
|
|
||||||
// The defect of the wallet the selected address belongs to, or null. Both the
|
// The defect of the wallet the selected address belongs to, or null. Both the
|
||||||
// send and the private-key export path check it before asking for a password,
|
// send and the private-key export path check it before asking for a password,
|
||||||
// so a wallet that cannot derive its keys says so instead of failing after the
|
// so a wallet whose key getSignerForAddress refuses says so instead of failing
|
||||||
// user has typed one in.
|
// after the user has typed one in.
|
||||||
function selectedWalletDefect() {
|
function selectedWalletDefect() {
|
||||||
if (state.selectedWallet === null) return null;
|
if (state.selectedWallet === null) return null;
|
||||||
return walletDefect(state.wallets[state.selectedWallet]);
|
return walletDefect(state.wallets[state.selectedWallet]);
|
||||||
@@ -66,7 +66,7 @@ function show() {
|
|||||||
$("address-line").dataset.full = addr.address;
|
$("address-line").dataset.full = addr.address;
|
||||||
attachCopyHandlers($("address-line"));
|
attachCopyHandlers($("address-line"));
|
||||||
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
||||||
$("address-usd-total").innerHTML = usdTotal || " ";
|
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
|
||||||
const ensEl = $("address-ens");
|
const ensEl = $("address-ens");
|
||||||
// ENS is now shown inside renderAddressHtml, hide the separate element
|
// ENS is now shown inside renderAddressHtml, hide the separate element
|
||||||
ensEl.classList.add("hidden");
|
ensEl.classList.add("hidden");
|
||||||
@@ -181,10 +181,10 @@ function renderTransactions(txs) {
|
|||||||
// it on the line above rather than replacing it.
|
// it on the line above rather than replacing it.
|
||||||
const nameStr = escapeHtml(title || ensName || "");
|
const nameStr = escapeHtml(title || ensName || "");
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
const opacity = tx.isError ? " opacity-50" : "";
|
||||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
@@ -259,9 +259,10 @@ function init(_ctx) {
|
|||||||
$("btn-export-privkey").addEventListener("click", () => {
|
$("btn-export-privkey").addEventListener("click", () => {
|
||||||
moreDropdown.classList.add("hidden");
|
moreDropdown.classList.add("hidden");
|
||||||
moreBtn.classList.remove("bg-fg", "text-bg");
|
moreBtn.classList.remove("bg-fg", "text-bg");
|
||||||
// There is no private key to export for an address this wallet
|
// This address's private key can be derived from the stored key,
|
||||||
// cannot derive. Without this the export screen would take a
|
// but export goes through getSignerForAddress, which refuses a key
|
||||||
// password and then report it as wrong.
|
// that is not a master key. Without this the export screen would
|
||||||
|
// take a password and then report that refusal as a wrong password.
|
||||||
const defect = selectedWalletDefect();
|
const defect = selectedWalletDefect();
|
||||||
if (defect) {
|
if (defect) {
|
||||||
showFlash(defect.shortMessage);
|
showFlash(defect.shortMessage);
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function show() {
|
|||||||
// USD total for this token only
|
// USD total for this token only
|
||||||
const usdVal = price && amount !== null ? amount * price : null;
|
const usdVal = price && amount !== null ? amount * price : null;
|
||||||
const usdStr = formatUsd(usdVal);
|
const usdStr = formatUsd(usdVal);
|
||||||
$("address-token-usd-total").innerHTML = usdStr || " ";
|
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " ";
|
||||||
|
|
||||||
// Single token balance line (no tokenId — not clickable here)
|
// Single token balance line (no tokenId — not clickable here)
|
||||||
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
|
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
|
||||||
@@ -148,9 +148,9 @@ function show() {
|
|||||||
if (tokenSymbol)
|
if (tokenSymbol)
|
||||||
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
|
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
|
||||||
if (tokenDecimals != null)
|
if (tokenDecimals != null)
|
||||||
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
|
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${escapeHtml(tokenDecimals)}</div>`;
|
||||||
if (tokenHolders != null)
|
if (tokenHolders != null)
|
||||||
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
|
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${escapeHtml(Number(tokenHolders).toLocaleString())}</div>`;
|
||||||
if (projectUrl)
|
if (projectUrl)
|
||||||
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
|
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
|
||||||
contractInfo.innerHTML = infoHtml;
|
contractInfo.innerHTML = infoHtml;
|
||||||
@@ -258,10 +258,10 @@ function renderTransactions(txs) {
|
|||||||
// it on the line above rather than replacing it.
|
// it on the line above rather than replacing it.
|
||||||
const nameStr = escapeHtml(title || ensName || "");
|
const nameStr = escapeHtml(title || ensName || "");
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
const opacity = tx.isError ? " opacity-50" : "";
|
||||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
|
|||||||
@@ -822,9 +822,10 @@ function setSignButtonBusy(busy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Say so on the approval screen itself, and disable the approve button, when
|
// Say so on the approval screen itself, and disable the approve button, when
|
||||||
// the address the approval was raised for belongs to a wallet whose keys
|
// the address the approval was raised for belongs to a wallet whose key
|
||||||
// cannot be derived. Without this the screen would take a password and fail
|
// getSignerForAddress refuses. Without this the screen would take a password
|
||||||
// after deriving it. Reject stays available; the wallet is not touched.
|
// and fail after deriving it. Reject stays available; the wallet is not
|
||||||
|
// touched.
|
||||||
// Returns true when it gated.
|
// Returns true when it gated.
|
||||||
function gateOnWalletDefect(errorId, buttonId, address) {
|
function gateOnWalletDefect(errorId, buttonId, address) {
|
||||||
const owner = findWalletFor(address);
|
const owner = findWalletFor(address);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const {
|
|||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentNetwork } = require("../../shared/state");
|
const { state, currentNetwork } = require("../../shared/state");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
|
const { walletDefect } = require("../../shared/walletDefects");
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
@@ -537,6 +538,15 @@ function init(_ctx) {
|
|||||||
onViewLeave("confirm-tx", clearPassword);
|
onViewLeave("confirm-tx", clearPassword);
|
||||||
|
|
||||||
$("btn-confirm-send").addEventListener("click", async () => {
|
$("btn-confirm-send").addEventListener("click", async () => {
|
||||||
|
const wallet = state.wallets[state.selectedWallet];
|
||||||
|
// Every Send button refuses a defective wallet before this screen,
|
||||||
|
// but the popup also reopens onto it from a saved view.
|
||||||
|
const defect = walletDefect(wallet);
|
||||||
|
if (defect) {
|
||||||
|
showError("confirm-tx-password-error", defect.shortMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const password = $("confirm-tx-password").value;
|
const password = $("confirm-tx-password").value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
showError(
|
showError(
|
||||||
@@ -546,7 +556,6 @@ function init(_ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wallet = state.wallets[state.selectedWallet];
|
|
||||||
let decryptedSecret;
|
let decryptedSecret;
|
||||||
hideError("confirm-tx-password-error");
|
hideError("confirm-tx-password-error");
|
||||||
|
|
||||||
|
|||||||
+24
-21
@@ -325,7 +325,7 @@ function balanceLine(symbol, amount, price, tokenId) {
|
|||||||
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||||
const usd =
|
const usd =
|
||||||
price && amount !== null
|
price && amount !== null
|
||||||
? formatUsd(amount * price) || " "
|
? escapeHtml(formatUsd(amount * price)) || " "
|
||||||
: " ";
|
: " ";
|
||||||
// tokenId is a contract address out of the same explorer JSON, and it
|
// tokenId is a contract address out of the same explorer JSON, and it
|
||||||
// lands inside a quoted attribute.
|
// lands inside a quoted attribute.
|
||||||
@@ -335,7 +335,7 @@ function balanceLine(symbol, amount, price, tokenId) {
|
|||||||
: "";
|
: "";
|
||||||
return (
|
return (
|
||||||
`<div class="flex text-xs${clickClass}"${tokenAttr}>` +
|
`<div class="flex text-xs${clickClass}"${tokenAttr}>` +
|
||||||
`<span class="flex justify-between" style="width:42ch;max-width:100%">` +
|
`<span class="flex justify-between w-[42ch] max-w-full">` +
|
||||||
`<span>${escapeHtml(displaySymbol(symbol))}</span>` +
|
`<span>${escapeHtml(displaySymbol(symbol))}</span>` +
|
||||||
`<span>${qty}</span>` +
|
`<span>${qty}</span>` +
|
||||||
`</span>` +
|
`</span>` +
|
||||||
@@ -430,23 +430,26 @@ function truncateMiddle(str, maxLen) {
|
|||||||
|
|
||||||
// 16 colors evenly spaced around the hue wheel (22.5° apart),
|
// 16 colors evenly spaced around the hue wheel (22.5° apart),
|
||||||
// all at HSL saturation 70%, lightness 50% for uniform vibrancy.
|
// all at HSL saturation 70%, lightness 50% for uniform vibrancy.
|
||||||
|
// Each is a whole Tailwind class: Tailwind builds only the classes it finds
|
||||||
|
// written out in the source, so the class name cannot be put together at
|
||||||
|
// runtime.
|
||||||
const ADDRESS_COLORS = [
|
const ADDRESS_COLORS = [
|
||||||
"#d92626",
|
"bg-[#d92626]",
|
||||||
"#d96926",
|
"bg-[#d96926]",
|
||||||
"#d9ac26",
|
"bg-[#d9ac26]",
|
||||||
"#c2d926",
|
"bg-[#c2d926]",
|
||||||
"#80d926",
|
"bg-[#80d926]",
|
||||||
"#3dd926",
|
"bg-[#3dd926]",
|
||||||
"#26d953",
|
"bg-[#26d953]",
|
||||||
"#26d996",
|
"bg-[#26d996]",
|
||||||
"#26d9d9",
|
"bg-[#26d9d9]",
|
||||||
"#2696d9",
|
"bg-[#2696d9]",
|
||||||
"#2653d9",
|
"bg-[#2653d9]",
|
||||||
"#3d26d9",
|
"bg-[#3d26d9]",
|
||||||
"#8026d9",
|
"bg-[#8026d9]",
|
||||||
"#c226d9",
|
"bg-[#c226d9]",
|
||||||
"#d926ac",
|
"bg-[#d926ac]",
|
||||||
"#d92669",
|
"bg-[#d92669]",
|
||||||
];
|
];
|
||||||
|
|
||||||
function addressColor(address) {
|
function addressColor(address) {
|
||||||
@@ -456,12 +459,12 @@ function addressColor(address) {
|
|||||||
|
|
||||||
function addressDotHtml(address) {
|
function addressDotHtml(address) {
|
||||||
const color = addressColor(address);
|
const color = addressColor(address);
|
||||||
return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`;
|
return `<span class="inline-block w-[8px] h-[8px] rounded-[50%] ${color} mr-[4px] align-middle shrink-0"></span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockieHtml(address) {
|
function blockieHtml(address) {
|
||||||
const src = makeBlockie(address);
|
const src = makeBlockie(address);
|
||||||
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
|
return `<img src="${escapeHtml(src)}" width="48" height="48" class="inline-block rounded-[50%] [image-rendering:pixelated]">`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up an address across all wallets and return its title
|
// Look up an address across all wallets and return its title
|
||||||
@@ -571,7 +574,7 @@ function timeAgo(timestamp) {
|
|||||||
|
|
||||||
// Shared external-link icon SVG used across all views.
|
// Shared external-link icon SVG used across all views.
|
||||||
const EXT_ICON =
|
const EXT_ICON =
|
||||||
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
`<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` +
|
||||||
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
|
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
|
||||||
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
|
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
|
||||||
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
||||||
|
|||||||
+12
-8
@@ -63,7 +63,7 @@ function renderTotalValue() {
|
|||||||
const ethPrice = getPrice("ETH");
|
const ethPrice = getPrice("ETH");
|
||||||
if (priceEl) {
|
if (priceEl) {
|
||||||
priceEl.innerHTML = ethPrice
|
priceEl.innerHTML = ethPrice
|
||||||
? formatUsd(ethPrice) + " USD/ETH"
|
? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
|
||||||
: " ";
|
: " ";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,8 @@ function renderTotalValue() {
|
|||||||
el.textContent = ethStr + ethUsd;
|
el.textContent = ethStr + ethUsd;
|
||||||
|
|
||||||
if (subEl) {
|
if (subEl) {
|
||||||
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || " ";
|
subEl.innerHTML =
|
||||||
|
escapeHtml(formatAddressTotal(getAddressValue(addr))) || " ";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +131,10 @@ function renderHomeTxList(ctx) {
|
|||||||
const title = addressTitle(counterparty, state.wallets);
|
const title = addressTitle(counterparty, state.wallets);
|
||||||
const titleStr = title ? escapeHtml(title) : "";
|
const titleStr = title ? escapeHtml(title) : "";
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
const opacity = tx.isError ? " opacity-50" : "";
|
||||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
|
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
@@ -224,6 +225,9 @@ async function loadHomeTxs(ctx) {
|
|||||||
homeTxs = merged.slice(0, 25);
|
homeTxs = merged.slice(0, 25);
|
||||||
renderHomeTxList(ctx);
|
renderHomeTxList(ctx);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
|
// src/popup/index.js.
|
||||||
|
if (ctx.pageClosed.aborted) return;
|
||||||
log.errorf("loadHomeTxs failed:", e.message);
|
log.errorf("loadHomeTxs failed:", e.message);
|
||||||
const list = $("home-tx-list");
|
const list = $("home-tx-list");
|
||||||
if (list) {
|
if (list) {
|
||||||
@@ -240,7 +244,7 @@ function walletListHtml() {
|
|||||||
state.wallets.forEach((wallet, wi) => {
|
state.wallets.forEach((wallet, wi) => {
|
||||||
const defect = walletDefect(wallet);
|
const defect = walletDefect(wallet);
|
||||||
html += `<div>`;
|
html += `<div>`;
|
||||||
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
|
html += `<div class="flex justify-between items-center bg-section py-1 px-2 -mx-2">`;
|
||||||
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`;
|
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`;
|
||||||
// No "+" on a defective wallet: deriving another address from that
|
// No "+" on a defective wallet: deriving another address from that
|
||||||
// xpub would only add one more address the key does not produce
|
// xpub would only add one more address the key does not produce
|
||||||
@@ -254,12 +258,12 @@ function walletListHtml() {
|
|||||||
wallet.addresses.forEach((addr, ai) => {
|
wallet.addresses.forEach((addr, ai) => {
|
||||||
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
|
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
|
||||||
const isActive = state.activeAddress === addr.address;
|
const isActive = state.activeAddress === addr.address;
|
||||||
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
|
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg p-0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
|
||||||
// Only where a wallet can spare the address: a wallet holding a
|
// Only where a wallet can spare the address: a wallet holding a
|
||||||
// single address has no remove control, because its last address
|
// single address has no remove control, because its last address
|
||||||
// is never removable.
|
// is never removable.
|
||||||
const removeBtn = canRemoveAddress(wallet)
|
const removeBtn = canRemoveAddress(wallet)
|
||||||
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1" style="padding:0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
|
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1 p-0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
|
||||||
: "";
|
: "";
|
||||||
const dot = addressDotHtml(addr.address);
|
const dot = addressDotHtml(addr.address);
|
||||||
const titleBold = isActive ? "font-bold" : "";
|
const titleBold = isActive ? "font-bold" : "";
|
||||||
@@ -280,7 +284,7 @@ function walletListHtml() {
|
|||||||
}
|
}
|
||||||
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
||||||
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
||||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || " "}</div>`;
|
||||||
html += balanceLinesForAddress(
|
html += balanceLinesForAddress(
|
||||||
addr,
|
addr,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
|
|||||||
@@ -213,12 +213,7 @@ function show() {
|
|||||||
versionClickCount = 0;
|
versionClickCount = 0;
|
||||||
|
|
||||||
// Show debug well if debug mode is already enabled
|
// Show debug well if debug mode is already enabled
|
||||||
const debugWell = $("settings-debug-well");
|
$("settings-debug-well").classList.toggle("hidden", !state.debugMode);
|
||||||
if (state.debugMode) {
|
|
||||||
debugWell.style.display = "";
|
|
||||||
} else {
|
|
||||||
debugWell.style.display = "none";
|
|
||||||
}
|
|
||||||
$("settings-debug-mode").checked = state.debugMode;
|
$("settings-debug-mode").checked = state.debugMode;
|
||||||
|
|
||||||
showView("settings");
|
showView("settings");
|
||||||
@@ -434,7 +429,7 @@ function init(ctx) {
|
|||||||
if (versionClickCount >= 10) {
|
if (versionClickCount >= 10) {
|
||||||
versionClickCount = 0;
|
versionClickCount = 0;
|
||||||
clearTimeout(versionClickTimer);
|
clearTimeout(versionClickTimer);
|
||||||
$("settings-debug-well").style.display = "";
|
$("settings-debug-well").classList.remove("hidden");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -87,7 +87,15 @@ function rawUnits(value) {
|
|||||||
// way `holders` already is. Absence is never filled in here: this is the
|
// way `holders` already is. Absence is never filled in here: this is the
|
||||||
// upstream of every screen that displays a token amount, so a value invented
|
// upstream of every screen that displays a token amount, so a value invented
|
||||||
// at this point is indistinguishable from a real one everywhere below it.
|
// at this point is indistinguishable from a real one everywhere below it.
|
||||||
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
//
|
||||||
|
// `signal`, passed by the popup, is aborted when the popup closes; a request
|
||||||
|
// that fails after that was cancelled by the closing and is not logged.
|
||||||
|
async function fetchTokenBalances(
|
||||||
|
address,
|
||||||
|
blockscoutUrl,
|
||||||
|
trackedTokens,
|
||||||
|
signal,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const resp = await debugFetch(
|
const resp = await debugFetch(
|
||||||
blockscoutUrl + "/addresses/" + address + "/token-balances",
|
blockscoutUrl + "/addresses/" + address + "/token-balances",
|
||||||
@@ -190,18 +198,22 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
|||||||
}
|
}
|
||||||
return balances;
|
return balances;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!signal?.aborted) {
|
||||||
log.errorf("fetchTokenBalances failed:", e.message);
|
log.errorf("fetchTokenBalances failed:", e.message);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
|
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
|
||||||
|
// `signal` is as for fetchTokenBalances().
|
||||||
async function refreshBalances(
|
async function refreshBalances(
|
||||||
wallets,
|
wallets,
|
||||||
rpcUrl,
|
rpcUrl,
|
||||||
blockscoutUrl,
|
blockscoutUrl,
|
||||||
trackedTokens,
|
trackedTokens,
|
||||||
networkId,
|
networkId,
|
||||||
|
signal,
|
||||||
) {
|
) {
|
||||||
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
@@ -220,6 +232,7 @@ async function refreshBalances(
|
|||||||
log.debugf("ETH balance", addr.address, addr.balance);
|
log.debugf("ETH balance", addr.address, addr.balance);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
if (signal?.aborted) return;
|
||||||
log.errorf(
|
log.errorf(
|
||||||
"ETH balance failed",
|
"ETH balance failed",
|
||||||
addr.address,
|
addr.address,
|
||||||
@@ -243,6 +256,7 @@ async function refreshBalances(
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
if (signal?.aborted) return;
|
||||||
log.errorf(
|
log.errorf(
|
||||||
"ENS reverse failed",
|
"ENS reverse failed",
|
||||||
addr.address,
|
addr.address,
|
||||||
@@ -258,6 +272,7 @@ async function refreshBalances(
|
|||||||
addr.address,
|
addr.address,
|
||||||
blockscoutUrl,
|
blockscoutUrl,
|
||||||
trackedTokens,
|
trackedTokens,
|
||||||
|
signal,
|
||||||
).then((balances) => {
|
).then((balances) => {
|
||||||
if (balances !== null) {
|
if (balances !== null) {
|
||||||
addr.tokenBalances = balances;
|
addr.tokenBalances = balances;
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
|
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
|
||||||
// truncated. The ellipsis is what tells the user the name they are looking
|
// truncated. The ellipsis is what tells the user the name they are looking
|
||||||
// at is not the whole name — worth knowing before they send to it.
|
// at is not the whole name — worth knowing before they send to it.
|
||||||
|
//
|
||||||
|
// Characters are counted as code points, not UTF-16 units, so an emoji is
|
||||||
|
// one character and the cut never falls between the two halves of one: a
|
||||||
|
// half on its own renders as U+FFFD.
|
||||||
|
|
||||||
const MAX_SYMBOL_LENGTH = 12;
|
const MAX_SYMBOL_LENGTH = 12;
|
||||||
|
|
||||||
@@ -32,8 +36,9 @@ const UNKNOWN_SYMBOL = "???";
|
|||||||
function displaySymbol(symbol) {
|
function displaySymbol(symbol) {
|
||||||
const s = symbol === null || symbol === undefined ? "" : String(symbol);
|
const s = symbol === null || symbol === undefined ? "" : String(symbol);
|
||||||
if (s.length === 0) return UNKNOWN_SYMBOL;
|
if (s.length === 0) return UNKNOWN_SYMBOL;
|
||||||
if (s.length <= MAX_SYMBOL_LENGTH) return s;
|
const chars = Array.from(s);
|
||||||
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
|
if (chars.length <= MAX_SYMBOL_LENGTH) return s;
|
||||||
|
return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -13,11 +13,17 @@ const NON_MASTER_XPRV = "non-master-xprv";
|
|||||||
|
|
||||||
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below
|
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below
|
||||||
// the key that was imported: the current import path derives the absolute
|
// the key that was imported: the current import path derives the absolute
|
||||||
// m/44'/60'/0'/0 from a depth-0 key, and the pre-#210 path derived the same
|
// m/44'/60'/0'/0 from a depth-0 key, and the path before #210 (57959b7)
|
||||||
// four levels as a relative path beneath whatever depth it was given. A master
|
// derived the same four levels as a relative path beneath whatever depth it
|
||||||
// import therefore stores a depth-4 xpub and a depth-d import stores depth
|
// was given. A master import therefore stores a depth-4 xpub and a depth-d
|
||||||
// d + 4, which makes the stored xpub an exact read on the imported key's
|
// import stores depth d + 4, which makes the stored xpub an exact read on the
|
||||||
// depth — and it is readable without the password, unlike the key itself.
|
// imported key's depth — and it is readable without the password, unlike the
|
||||||
|
// key itself.
|
||||||
|
//
|
||||||
|
// The first import path (7a7f9c5) does not fit: it stored the imported key's
|
||||||
|
// own xpub with no derivation, so a wallet it wrote is judged wrongly here (a
|
||||||
|
// master import as defective, a depth-4 import as sound). 57959b7 replaced it
|
||||||
|
// in the same push, and no tag contains it.
|
||||||
const BIP44_ETH_XPUB_DEPTH = 4;
|
const BIP44_ETH_XPUB_DEPTH = 4;
|
||||||
|
|
||||||
const DEFECTS = {
|
const DEFECTS = {
|
||||||
|
|||||||
@@ -194,6 +194,14 @@ describe("the wallet list on Home", () => {
|
|||||||
clearPrices();
|
clearPrices();
|
||||||
expect(walletListTotal(FULLY_PRICED)).toBe(" ");
|
expect(walletListTotal(FULLY_PRICED)).toBe(" ");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A total under a cent is written "< $0.01", and the "<" is escaped
|
||||||
|
// here as the removal warning escapes it.
|
||||||
|
test("a total under a cent is escaped, as on the removal warning", () => {
|
||||||
|
const tiny = { ...EMPTY, balance: "0.000001" };
|
||||||
|
expect(walletListTotal(tiny)).toBe("Total: < $0.01");
|
||||||
|
expect(removalWarningTotal(tiny)).toBe("Total: < $0.01");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the balance warning on the address-removal confirmation", () => {
|
describe("the balance warning on the address-removal confirmation", () => {
|
||||||
|
|||||||
@@ -66,4 +66,11 @@ describe("balanceLine", () => {
|
|||||||
expect(html).toContain("<span>1.5000</span>");
|
expect(html).toContain("<span>1.5000</span>");
|
||||||
expect(html).toContain('data-token="0xabc"');
|
expect(html).toContain('data-token="0xabc"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// formatUsd() writes a value under a cent as "< $0.01".
|
||||||
|
test("escapes the USD value along with the symbol", () => {
|
||||||
|
const html = balanceLine("USDC", 0.001, 1, null);
|
||||||
|
expect(html).toContain("< $0.01");
|
||||||
|
expect(html).not.toContain("< $0.01");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// The balance refresh does not report a request the popup's own closing
|
||||||
|
// cancelled, and still reports one that failed while the popup was open
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/218).
|
||||||
|
//
|
||||||
|
// In the popup a cancelled fetch() fails with the same "Failed to fetch" as a
|
||||||
|
// server that cannot be reached, so every request here fails that way, and
|
||||||
|
// only the signal the popup aborts on pagehide tells the two cases apart.
|
||||||
|
|
||||||
|
const { FetchRequest } = require("ethers");
|
||||||
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
|
||||||
|
const RPC_URL = "https://rpc.example.invalid";
|
||||||
|
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
|
||||||
|
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||||
|
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
let logged;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
logged = [];
|
||||||
|
jest.spyOn(console, "error").mockImplementation((...args) => {
|
||||||
|
logged.push(args.map(String).join(" "));
|
||||||
|
});
|
||||||
|
const failedToFetch = async () => {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
};
|
||||||
|
// The RPC calls (ETH balance, ENS name) and the explorer request (token
|
||||||
|
// balances) all fail the same way.
|
||||||
|
FetchRequest.registerGetUrl(failedToFetch);
|
||||||
|
globalThis.fetch = jest.fn(failedToFetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function refresh(signal) {
|
||||||
|
const wallets = [{ addresses: [{ address: ADDRESS }] }];
|
||||||
|
return refreshBalances(
|
||||||
|
wallets,
|
||||||
|
RPC_URL,
|
||||||
|
EXPLORER_URL,
|
||||||
|
[],
|
||||||
|
"mainnet",
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a failure while the popup is open is reported", async () => {
|
||||||
|
await refresh(new AbortController().signal);
|
||||||
|
for (const label of [
|
||||||
|
"ETH balance failed",
|
||||||
|
"ENS reverse failed",
|
||||||
|
"fetchTokenBalances failed: Failed to fetch",
|
||||||
|
]) {
|
||||||
|
expect(logged.some((line) => line.includes(label))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failure once the popup has closed is not", async () => {
|
||||||
|
const pageClosed = new AbortController();
|
||||||
|
pageClosed.abort();
|
||||||
|
await refresh(pageClosed.signal);
|
||||||
|
expect(logged).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// `make dev` runs `node build.js --watch`
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/332). These drive build.js's
|
||||||
|
// watch() over a temp directory with a stand-in for build(), so nothing here
|
||||||
|
// builds or writes dist/.
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const { watch } = require("../build");
|
||||||
|
|
||||||
|
let dir;
|
||||||
|
let watchers = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-watch-"));
|
||||||
|
fs.mkdirSync(path.join(dir, "nested"));
|
||||||
|
jest.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const watcher of watchers) watcher.close();
|
||||||
|
watchers = [];
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// Wait until `condition()` holds; fail the test if it has not within 3s.
|
||||||
|
async function until(condition) {
|
||||||
|
const deadline = Date.now() + 3000;
|
||||||
|
while (!condition()) {
|
||||||
|
if (Date.now() > deadline) throw new Error("timed out waiting");
|
||||||
|
await sleep(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the way many editors do: write a new copy, then rename it over the
|
||||||
|
// original, so the file at that path is a different one afterwards.
|
||||||
|
function saveByRename(file, contents) {
|
||||||
|
fs.writeFileSync(`${file}.tmp`, contents);
|
||||||
|
fs.renameSync(`${file}.tmp`, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("builds at start, then once per change to a file in a subdirectory", async () => {
|
||||||
|
const file = path.join(dir, "nested", "a.js");
|
||||||
|
fs.writeFileSync(file, "1");
|
||||||
|
let builds = 0;
|
||||||
|
watchers = watch([dir], async () => {
|
||||||
|
builds++;
|
||||||
|
});
|
||||||
|
await until(() => builds === 1);
|
||||||
|
|
||||||
|
fs.writeFileSync(file, "2");
|
||||||
|
await until(() => builds === 2);
|
||||||
|
await sleep(300);
|
||||||
|
expect(builds).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps seeing a file that is saved by renaming a new copy over it", async () => {
|
||||||
|
const file = path.join(dir, "nested", "a.js");
|
||||||
|
fs.writeFileSync(file, "1");
|
||||||
|
let builds = 0;
|
||||||
|
watchers = watch([dir], async () => {
|
||||||
|
builds++;
|
||||||
|
});
|
||||||
|
await until(() => builds === 1);
|
||||||
|
|
||||||
|
saveByRename(file, "2");
|
||||||
|
await until(() => builds === 2);
|
||||||
|
saveByRename(file, "3");
|
||||||
|
await until(() => builds === 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed build is reported and watching carries on", async () => {
|
||||||
|
let builds = 0;
|
||||||
|
watchers = watch([dir], async () => {
|
||||||
|
builds++;
|
||||||
|
if (builds === 1) throw new Error("unexpected token");
|
||||||
|
});
|
||||||
|
await until(() => console.error.mock.calls.length === 1);
|
||||||
|
expect(console.error).toHaveBeenCalledWith(
|
||||||
|
"Build failed: unexpected token",
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(dir, "a.js"), "1");
|
||||||
|
await until(() => builds === 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a change made while a build runs causes one more build after it", async () => {
|
||||||
|
let builds = 0;
|
||||||
|
watchers = watch([dir], async () => {
|
||||||
|
builds++;
|
||||||
|
if (builds === 1) {
|
||||||
|
fs.writeFileSync(path.join(dir, "a.js"), "1");
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await until(() => builds === 2);
|
||||||
|
await sleep(300);
|
||||||
|
expect(builds).toBe(2);
|
||||||
|
});
|
||||||
@@ -301,7 +301,7 @@ describe.each([
|
|||||||
test("a contract creation's row says so, with no colour dot and no address line", async () => {
|
test("a contract creation's row says so, with no colour dot and no address line", async () => {
|
||||||
const html = await rowsFor(historyTx(""));
|
const html = await rowsFor(historyTx(""));
|
||||||
expect(html).toContain(SENTENCE);
|
expect(html).toContain(SENTENCE);
|
||||||
expect(html).not.toContain("background:");
|
expect(html).not.toContain("bg-[#");
|
||||||
expect(html).not.toContain("am-address");
|
expect(html).not.toContain("am-address");
|
||||||
expect(html).not.toContain("undefined");
|
expect(html).not.toContain("undefined");
|
||||||
});
|
});
|
||||||
@@ -309,7 +309,7 @@ describe.each([
|
|||||||
test("a transaction with a recipient shows its colour dot and address", async () => {
|
test("a transaction with a recipient shows its colour dot and address", async () => {
|
||||||
const html = await rowsFor(historyTx(RECIPIENT));
|
const html = await rowsFor(historyTx(RECIPIENT));
|
||||||
expectAddressLine(html);
|
expectAddressLine(html);
|
||||||
expect(html).toContain("background:#");
|
expect(html).toContain("bg-[#");
|
||||||
expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`);
|
expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const { isDeepStrictEqual } = require("util");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Transaction,
|
Transaction,
|
||||||
@@ -62,6 +63,10 @@ const {
|
|||||||
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
||||||
const { startDappServer } = require("./dapp");
|
const { startDappServer } = require("./dapp");
|
||||||
const { STUB_COUNTERPARTY } = require("../network");
|
const { STUB_COUNTERPARTY } = require("../network");
|
||||||
|
const {
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
stateProblem,
|
||||||
|
} = require("../../../src/shared/stateSchema");
|
||||||
|
|
||||||
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||||
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
|
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
|
||||||
@@ -122,6 +127,123 @@ step("the popup is drawn in the monospace font it declares", async (env) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The recovery screen (#361): the Chrome suite's four cases, run before any
|
||||||
|
// wallet exists for the same reason. With no wallet nothing saves on a timer,
|
||||||
|
// so no save can write a good record over the unreadable one. The last of them
|
||||||
|
// erases it, which leaves the popup on Welcome for wallet creation.
|
||||||
|
|
||||||
|
// A profile a newer build wrote: a wallet with its encrypted secret, under a
|
||||||
|
// schema version this build refuses to read.
|
||||||
|
const UNREADABLE_RECORD = {
|
||||||
|
schemaVersion: STATE_SCHEMA_VERSION + 1,
|
||||||
|
wallets: [
|
||||||
|
{
|
||||||
|
type: "hd",
|
||||||
|
name: "Main",
|
||||||
|
xpub: "xpub-written-by-a-newer-build",
|
||||||
|
encryptedSecret: "ciphertext-written-by-a-newer-build",
|
||||||
|
nextIndex: 1,
|
||||||
|
addresses: [{ address: STUB_COUNTERPARTY }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// The whole stored record, read on the popup page.
|
||||||
|
function storedRecord(d) {
|
||||||
|
return d.executeAsync(
|
||||||
|
`const done = arguments[arguments.length - 1];
|
||||||
|
browser.storage.local.get("autistmask").then((r) => done(r.autistmask));`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
step(
|
||||||
|
"an unreadable stored record opens the popup on the recovery screen",
|
||||||
|
async (env) => {
|
||||||
|
const d = env.driver;
|
||||||
|
// The popup the first step opened saves once, as it shows Welcome.
|
||||||
|
// Stored before that save lands, the record would be written over.
|
||||||
|
const deadline = Date.now() + 15000;
|
||||||
|
for (;;) {
|
||||||
|
const stored = await storedRecord(d);
|
||||||
|
if (stored && stored.currentView === "welcome") break;
|
||||||
|
assert(
|
||||||
|
Date.now() < deadline,
|
||||||
|
"the Welcome screen's save never landed: " +
|
||||||
|
JSON.stringify(stored),
|
||||||
|
);
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
await d.executeAsync(
|
||||||
|
`const done = arguments[arguments.length - 1];
|
||||||
|
browser.storage.local.set({ autistmask: arguments[0] }).then(() => done());`,
|
||||||
|
[UNREADABLE_RECORD],
|
||||||
|
);
|
||||||
|
|
||||||
|
await d.navigate(POPUP_URL);
|
||||||
|
await d.waitVisible("#view-state-recovery");
|
||||||
|
const problem = await d.text("#state-recovery-problem");
|
||||||
|
assert(
|
||||||
|
problem === stateProblem(UNREADABLE_RECORD),
|
||||||
|
"the recovery screen names the problem as " +
|
||||||
|
JSON.stringify(problem),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
step("Export Saved Data shows the stored record verbatim", async (env) => {
|
||||||
|
const d = env.driver;
|
||||||
|
await d.click("#btn-state-recovery-export");
|
||||||
|
await d.waitVisible("#state-recovery-blob");
|
||||||
|
const exported = await d.value("#state-recovery-blob");
|
||||||
|
assert(exported !== "", "Export Saved Data left the text box empty");
|
||||||
|
assert(
|
||||||
|
isDeepStrictEqual(JSON.parse(exported), UNREADABLE_RECORD),
|
||||||
|
"the text box does not hold the stored record: " + exported,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
step("a near-miss confirmation phrase erases nothing", async (env) => {
|
||||||
|
const d = env.driver;
|
||||||
|
await d.fill("#state-recovery-reset-input", "ERASE MY WALLETS");
|
||||||
|
await d.click("#btn-state-recovery-reset");
|
||||||
|
await d.waitFor(
|
||||||
|
"the refusal on the error line",
|
||||||
|
`return document.getElementById("state-recovery-flash").textContent ===
|
||||||
|
"Type ERASE MY WALLET to confirm. Nothing was erased.";`,
|
||||||
|
);
|
||||||
|
const stored = await storedRecord(d);
|
||||||
|
assert(
|
||||||
|
isDeepStrictEqual(stored, UNREADABLE_RECORD),
|
||||||
|
"the stored record changed: " + JSON.stringify(stored),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
step(
|
||||||
|
"the exact confirmation phrase erases the record and reloads into Welcome",
|
||||||
|
async (env) => {
|
||||||
|
const d = env.driver;
|
||||||
|
try {
|
||||||
|
await d.fill("#state-recovery-reset-input", "ERASE MY WALLET");
|
||||||
|
await d.click("#btn-state-recovery-reset");
|
||||||
|
// Welcome is the proof of the erase: the record still stored
|
||||||
|
// would put the recovery screen up again, and its wallet would
|
||||||
|
// open Home.
|
||||||
|
await d.waitVisible("#view-welcome");
|
||||||
|
} finally {
|
||||||
|
// Whatever failed in these four steps, wallet creation starts
|
||||||
|
// from Welcome. The record left stored would fail every step
|
||||||
|
// after this.
|
||||||
|
if (!(await d.isVisible("#view-welcome"))) {
|
||||||
|
await d.executeAsync(
|
||||||
|
`const done = arguments[arguments.length - 1];
|
||||||
|
browser.storage.local.remove("autistmask").then(() => done());`,
|
||||||
|
);
|
||||||
|
await d.navigate(POPUP_URL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
step("wallet creation through the UI reaches the main view", async (env) => {
|
step("wallet creation through the UI reaches the main view", async (env) => {
|
||||||
const d = env.driver;
|
const d = env.driver;
|
||||||
await d.click("#btn-welcome-add");
|
await d.click("#btn-welcome-add");
|
||||||
|
|||||||
+50
-21
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const { Transaction } = require("ethers");
|
const { AbiCoder, Transaction } = require("ethers");
|
||||||
|
|
||||||
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
||||||
// must not collide with any entry in src/shared/tokenList.js, or
|
// must not collide with any entry in src/shared/tokenList.js, or
|
||||||
@@ -244,12 +244,16 @@ function latestBlock() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// keccak("decimals()")[0:4].
|
// keccak("decimals()")[0:4], and the same for symbol() and name().
|
||||||
const SELECTOR_DECIMALS = "0x313ce567";
|
const SELECTOR_DECIMALS = "0x313ce567";
|
||||||
|
const SELECTOR_SYMBOL = "0x95d89b41";
|
||||||
|
const SELECTOR_NAME = "0x06fdde03";
|
||||||
|
|
||||||
// Every eth_call still answers with a zero word except decimals() on the
|
// Every eth_call still answers with a zero word except decimals(), symbol()
|
||||||
// stub token, which the wallet reads back at signing time to compare with
|
// and name() on the stub token. The wallet reads decimals() back at signing
|
||||||
// the scale the confirmation screen rendered (issue #305).
|
// time to compare with the scale the confirmation screen rendered (issue
|
||||||
|
// #305). Adding the token by its contract address reads all three (issue
|
||||||
|
// #295); symbol() and name() answer what the explorer reports for it.
|
||||||
//
|
//
|
||||||
// opts.tokenDecimalsOverride is the lying contract: set it and decimals()
|
// opts.tokenDecimalsOverride is the lying contract: set it and decimals()
|
||||||
// answers something other than the value this same fixture reports through
|
// answers something other than the value this same fixture reports through
|
||||||
@@ -263,9 +267,17 @@ function ethCallResult(req, opts) {
|
|||||||
if (!call || typeof call !== "object") return ZERO_WORD;
|
if (!call || typeof call !== "object") return ZERO_WORD;
|
||||||
const data = String(call.data || call.input || "").toLowerCase();
|
const data = String(call.data || call.input || "").toLowerCase();
|
||||||
const to = String(call.to || "").toLowerCase();
|
const to = String(call.to || "").toLowerCase();
|
||||||
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
|
if (to !== STUB_TOKEN.address) return ZERO_WORD;
|
||||||
|
if (data.startsWith(SELECTOR_DECIMALS)) {
|
||||||
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
|
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
|
||||||
}
|
}
|
||||||
|
const abi = AbiCoder.defaultAbiCoder();
|
||||||
|
if (data.startsWith(SELECTOR_SYMBOL)) {
|
||||||
|
return abi.encode(["string"], [tokenObject(opts).symbol]);
|
||||||
|
}
|
||||||
|
if (data.startsWith(SELECTOR_NAME)) {
|
||||||
|
return abi.encode(["string"], [tokenObject(opts).name]);
|
||||||
|
}
|
||||||
return ZERO_WORD;
|
return ZERO_WORD;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,27 +418,23 @@ function sleep(ms) {
|
|||||||
const HOLD_POLL_MS = 25;
|
const HOLD_POLL_MS = 25;
|
||||||
const HOLD_MAX_MS = 30000;
|
const HOLD_MAX_MS = 30000;
|
||||||
|
|
||||||
// Hold a gas estimate open for as long as the test asks.
|
// Hold a reply open for as long as the test asks: until opts[name] is false.
|
||||||
//
|
//
|
||||||
// opts.holdGasEstimate is read here rather than captured, so a test flips it
|
// The switch (holdGasEstimate or holdBlockscout) is read here rather than
|
||||||
// on the same options object the route was registered with — the same
|
// captured, so a test flips it on the same options object the route was
|
||||||
// pattern as seedTokenTransfer. This is the only way to observe the
|
// registered with — the same pattern as seedTokenTransfer. This is the only way
|
||||||
// confirmation screen while its estimate is genuinely in flight; sampling
|
// to observe a screen while its request is genuinely in flight; sampling the
|
||||||
// the screen and hoping to win a race against the network would assert
|
// screen and hoping to win a race against the network would assert nothing on
|
||||||
// nothing on a slow machine.
|
// a slow machine.
|
||||||
//
|
//
|
||||||
// It never gives up quietly. A hold that outlives the bound is reported like
|
// It never gives up quietly. A hold that outlives the bound is reported like
|
||||||
// any other harness fault, because a "pending" state that stopped being
|
// any other harness fault, because a "pending" state that stopped being
|
||||||
// pending on its own is a green assertion about the wrong screen.
|
// pending on its own is a green assertion about the wrong screen.
|
||||||
async function awaitRelease(opts, report) {
|
async function awaitRelease(opts, name, report) {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
while (opts.holdGasEstimate) {
|
while (opts[name]) {
|
||||||
if (Date.now() - started > HOLD_MAX_MS) {
|
if (Date.now() - started > HOLD_MAX_MS) {
|
||||||
report(
|
report(name + " was never released after " + HOLD_MAX_MS + "ms");
|
||||||
"held gas estimate was never released after " +
|
|
||||||
HOLD_MAX_MS +
|
|
||||||
"ms",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sleep(HOLD_POLL_MS);
|
await sleep(HOLD_POLL_MS);
|
||||||
@@ -448,6 +456,16 @@ function rpcReply(req, opts, report) {
|
|||||||
return Object.assign(envelope, { result: ethCallResult(req, opts) });
|
return Object.assign(envelope, { result: ethCallResult(req, opts) });
|
||||||
}
|
}
|
||||||
if (req.method === "eth_getTransactionReceipt") {
|
if (req.method === "eth_getTransactionReceipt") {
|
||||||
|
// A lookup that fails, which the wait screen counts differently from
|
||||||
|
// one that answers "not mined yet" (README.md, WaitTx).
|
||||||
|
if (opts.failReceiptLookup) {
|
||||||
|
return Object.assign(envelope, {
|
||||||
|
error: {
|
||||||
|
code: -32000,
|
||||||
|
message: "e2e fixture: receipt lookup failed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
const hash = Array.isArray(req.params) ? req.params[0] : null;
|
const hash = Array.isArray(req.params) ? req.params[0] : null;
|
||||||
return Object.assign(envelope, {
|
return Object.assign(envelope, {
|
||||||
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
|
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
|
||||||
@@ -540,7 +558,7 @@ async function handleRpc(route, postData, opts, report) {
|
|||||||
return route.abort();
|
return route.abort();
|
||||||
}
|
}
|
||||||
if (batch.some((req) => req.method === "eth_estimateGas")) {
|
if (batch.some((req) => req.method === "eth_estimateGas")) {
|
||||||
await awaitRelease(opts, report);
|
await awaitRelease(opts, "holdGasEstimate", report);
|
||||||
}
|
}
|
||||||
|
|
||||||
const replies = batch.map((req) => rpcReply(req, opts, report));
|
const replies = batch.map((req) => rpcReply(req, opts, report));
|
||||||
@@ -596,6 +614,10 @@ function traceEnabled(raw) {
|
|||||||
* node-side refusal.
|
* node-side refusal.
|
||||||
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
|
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
|
||||||
* eth_estimateGas until this is cleared again.
|
* eth_estimateGas until this is cleared again.
|
||||||
|
* @param {boolean} [opts.holdBlockscout] hold every Blockscout request until
|
||||||
|
* this is cleared again.
|
||||||
|
* @param {boolean} [opts.failTransactionList] fail every request for an
|
||||||
|
* address's transaction list as a network error; read at request time.
|
||||||
* @param {string[]} [opts.broadcastTransactions] every raw signed
|
* @param {string[]} [opts.broadcastTransactions] every raw signed
|
||||||
* transaction handed to eth_sendRawTransaction, appended in order.
|
* transaction handed to eth_sendRawTransaction, appended in order.
|
||||||
* @param {number|string|null} [opts.tokenDecimalsOverride] the scale
|
* @param {number|string|null} [opts.tokenDecimalsOverride] the scale
|
||||||
@@ -607,6 +629,8 @@ function traceEnabled(raw) {
|
|||||||
* symbol is markup; read at request time.
|
* symbol is markup; read at request time.
|
||||||
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
|
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
|
||||||
* confirmed receipt instead of null, so a wait screen resolves.
|
* confirmed receipt instead of null, so a wait screen resolves.
|
||||||
|
* @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt
|
||||||
|
* with an error, so every receipt lookup fails; read at request time.
|
||||||
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
|
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
|
||||||
* Promise<string|null>}>}
|
* Promise<string|null>}>}
|
||||||
*/
|
*/
|
||||||
@@ -669,7 +693,12 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
|
|
||||||
// Blockscout v2
|
// Blockscout v2
|
||||||
if (p.includes("/api/v2/")) {
|
if (p.includes("/api/v2/")) {
|
||||||
|
await awaitRelease(opts, "holdBlockscout", report);
|
||||||
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
||||||
|
// Aborted rather than answered with an error status: to the
|
||||||
|
// page this is a server that cannot be reached, and fetch()
|
||||||
|
// rejects with "Failed to fetch".
|
||||||
|
if (opts.failTransactionList) return route.abort();
|
||||||
const addr = blockscoutAddress(p);
|
const addr = blockscoutAddress(p);
|
||||||
return jsonResponse(route, {
|
return jsonResponse(route, {
|
||||||
items:
|
items:
|
||||||
|
|||||||
+529
-33
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
const { isDeepStrictEqual } = require("util");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Transaction,
|
Transaction,
|
||||||
formatEther,
|
formatEther,
|
||||||
@@ -47,6 +49,10 @@ const {
|
|||||||
} = require("./network");
|
} = require("./network");
|
||||||
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
|
const { DUST_THRESHOLD_MESSAGE } = require("../../src/popup/dustThreshold");
|
||||||
const { NETWORKS } = require("../../src/shared/networks");
|
const { NETWORKS } = require("../../src/shared/networks");
|
||||||
|
const {
|
||||||
|
STATE_SCHEMA_VERSION,
|
||||||
|
stateProblem,
|
||||||
|
} = require("../../src/shared/stateSchema");
|
||||||
|
|
||||||
const TEST_TIMEOUT_MS = 120000;
|
const TEST_TIMEOUT_MS = 120000;
|
||||||
|
|
||||||
@@ -115,8 +121,8 @@ test("the popup is drawn in the monospace font it declares (#418)", async (env)
|
|||||||
// so a recurrence fails whichever test it lands in rather than being
|
// so a recurrence fails whichever test it lands in rather than being
|
||||||
// tolerated. Since libsodium's WASM module is embedded in the bundle and
|
// tolerated. Since libsodium's WASM module is embedded in the bundle and
|
||||||
// needs no fetch, a realm that compiles WASM is a realm where libsodium
|
// needs no fetch, a realm that compiles WASM is a realm where libsodium
|
||||||
// takes the WASM path, and the next test drives a real vault encryption
|
// takes the WASM path, and wallet creation below drives a real vault
|
||||||
// through it.
|
// encryption through it.
|
||||||
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
|
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
|
||||||
const ok = await pageCompilesWasm(env.page);
|
const ok = await pageCompilesWasm(env.page);
|
||||||
assert(
|
assert(
|
||||||
@@ -128,6 +134,121 @@ test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env)
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The screen the popup shows when it cannot read the stored profile
|
||||||
|
// (src/popup/views/stateRecovery.js), driven under the shipped CSP (#361).
|
||||||
|
//
|
||||||
|
// These run before any wallet exists, on purpose. With no wallet neither the
|
||||||
|
// popup nor the background refreshes balances, so nothing saves while they run
|
||||||
|
// and no save can write a good record over the unreadable one. The last of
|
||||||
|
// them erases it, which leaves the popup on Welcome for wallet creation.
|
||||||
|
|
||||||
|
// A profile a newer build wrote: a wallet with its encrypted secret, under a
|
||||||
|
// schema version this build refuses to read.
|
||||||
|
const UNREADABLE_RECORD = {
|
||||||
|
schemaVersion: STATE_SCHEMA_VERSION + 1,
|
||||||
|
wallets: [
|
||||||
|
{
|
||||||
|
type: "hd",
|
||||||
|
name: "Main",
|
||||||
|
xpub: "xpub-written-by-a-newer-build",
|
||||||
|
encryptedSecret: "ciphertext-written-by-a-newer-build",
|
||||||
|
nextIndex: 1,
|
||||||
|
addresses: [{ address: STUB_COUNTERPARTY }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// The whole stored record, read out of extension storage.
|
||||||
|
function storedRecord(page) {
|
||||||
|
return page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
chrome.storage.local.get("autistmask", (r) =>
|
||||||
|
resolve(r.autistmask),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an unreadable stored record opens the popup on the recovery screen (#361)", async (env) => {
|
||||||
|
// The popup the first test opened saves once, as it shows Welcome. Stored
|
||||||
|
// before that save lands, the record would be written over.
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"currentView",
|
||||||
|
"welcome",
|
||||||
|
"before the unreadable record is stored",
|
||||||
|
);
|
||||||
|
await env.page.evaluate(
|
||||||
|
(record) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
chrome.storage.local.set({ autistmask: record }, resolve);
|
||||||
|
}),
|
||||||
|
UNREADABLE_RECORD,
|
||||||
|
);
|
||||||
|
await env.page.close();
|
||||||
|
|
||||||
|
env.errors.expect(
|
||||||
|
"the recovery screen logging the problem as it goes up",
|
||||||
|
/state is unusable, showing the recovery screen/,
|
||||||
|
);
|
||||||
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
||||||
|
await visible(env.page, "#view-state-recovery");
|
||||||
|
const problem = await env.page.textContent("#state-recovery-problem");
|
||||||
|
assert(
|
||||||
|
problem === stateProblem(UNREADABLE_RECORD),
|
||||||
|
"the recovery screen names the problem as " + JSON.stringify(problem),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Export Saved Data shows the stored record verbatim (#361)", async (env) => {
|
||||||
|
await env.page.click("#btn-state-recovery-export");
|
||||||
|
await visible(env.page, "#state-recovery-blob");
|
||||||
|
const exported = await env.page.inputValue("#state-recovery-blob");
|
||||||
|
assert(exported !== "", "Export Saved Data left the text box empty");
|
||||||
|
assert(
|
||||||
|
isDeepStrictEqual(JSON.parse(exported), UNREADABLE_RECORD),
|
||||||
|
"the text box does not hold the stored record: " + exported,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a near-miss confirmation phrase erases nothing (#361)", async (env) => {
|
||||||
|
await env.page.fill("#state-recovery-reset-input", "ERASE MY WALLETS");
|
||||||
|
await env.page.click("#btn-state-recovery-reset");
|
||||||
|
await env.page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.getElementById("state-recovery-flash").textContent ===
|
||||||
|
"Type ERASE MY WALLET to confirm. Nothing was erased.",
|
||||||
|
);
|
||||||
|
const stored = await storedRecord(env.page);
|
||||||
|
assert(
|
||||||
|
isDeepStrictEqual(stored, UNREADABLE_RECORD),
|
||||||
|
"the stored record changed: " + JSON.stringify(stored),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the exact confirmation phrase erases the record and reloads into Welcome (#361)", async (env) => {
|
||||||
|
try {
|
||||||
|
await env.page.fill("#state-recovery-reset-input", "ERASE MY WALLET");
|
||||||
|
await env.page.click("#btn-state-recovery-reset");
|
||||||
|
// Welcome is the proof of the erase: the record still stored would
|
||||||
|
// put the recovery screen up again, and its wallet would open Home.
|
||||||
|
await visible(env.page, "#view-welcome");
|
||||||
|
} finally {
|
||||||
|
// Whatever failed in these four tests, wallet creation starts from
|
||||||
|
// Welcome. The record left stored would fail every test after this.
|
||||||
|
if (!(await env.page.isVisible("#view-welcome"))) {
|
||||||
|
await env.page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
chrome.storage.local.remove("autistmask", resolve);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await env.page.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("wallet creation through the UI reaches the main view", async (env) => {
|
test("wallet creation through the UI reaches the main view", async (env) => {
|
||||||
env.phrase = await createWallet(env.page);
|
env.phrase = await createWallet(env.page);
|
||||||
assert(
|
assert(
|
||||||
@@ -190,7 +311,7 @@ test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
|
|||||||
"token contract row missing the contract address, got: " +
|
"token contract row missing the contract address, got: " +
|
||||||
JSON.stringify(contractText),
|
JSON.stringify(contractText),
|
||||||
);
|
);
|
||||||
const dots = await contract.locator('span[style*="border-radius"]').count();
|
const dots = await contract.locator('span[class*="rounded-[50%]"]').count();
|
||||||
assert(dots > 0, "token contract row rendered without its colour dot");
|
assert(dots > 0, "token contract row rendered without its colour dot");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -475,6 +596,162 @@ test("tap-to-copy on the transaction detail screen copies the address (#151)", a
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------- the last of the #150 and #151 items (#295)
|
||||||
|
//
|
||||||
|
// Add Token's confirm button, TransactionDetail opened from the token screen
|
||||||
|
// and Back from it, and the explorer link on the token contract row.
|
||||||
|
|
||||||
|
// The stub token stays tracked for the rest of the run: the next test reaches
|
||||||
|
// its token screen through the balance row this one adds.
|
||||||
|
test("a token added by its contract address is listed on the address screen (#150)", async (env) => {
|
||||||
|
await leaveTransactionDetail(env.page);
|
||||||
|
await env.page.click("#btn-add-token");
|
||||||
|
await visible(env.page, "#view-add-token");
|
||||||
|
|
||||||
|
await env.page.fill("#add-token-address", STUB_TOKEN.address);
|
||||||
|
await env.page.click("#btn-add-token-confirm");
|
||||||
|
await visible(env.page, "#view-address");
|
||||||
|
|
||||||
|
// No wait: the confirm renders the balance list before it shows the
|
||||||
|
// screen, and nothing renders the list again while the screen is up.
|
||||||
|
const row = env.page.locator(
|
||||||
|
'#address-balances [data-token="' + STUB_TOKEN.address + '"]',
|
||||||
|
{ hasText: STUB_TOKEN.symbol },
|
||||||
|
);
|
||||||
|
const balances = await env.page.locator("#address-balances").innerText();
|
||||||
|
assert(
|
||||||
|
(await row.count()) === 1,
|
||||||
|
"the balance list has no " +
|
||||||
|
STUB_TOKEN.symbol +
|
||||||
|
" row for the token just added: " +
|
||||||
|
JSON.stringify(balances),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TransactionDetail looks the same from either entry point. Only the
|
||||||
|
// persisted stack says which one opened it, so that is what is asserted: from
|
||||||
|
// the token screen it ends in "address-token", and Back has to land there
|
||||||
|
// rather than on the address screen beneath it.
|
||||||
|
test("transaction detail opened from the token screen goes Back to it (#151)", async (env) => {
|
||||||
|
await goHome(env.page);
|
||||||
|
const base = await persistedViewStack(env.page);
|
||||||
|
|
||||||
|
await env.page.locator("#wallet-list .btn-addr-info").first().click();
|
||||||
|
await visible(env.page, "#view-address");
|
||||||
|
await env.page
|
||||||
|
.locator('#address-balances [data-token="' + STUB_TOKEN.address + '"]')
|
||||||
|
.click();
|
||||||
|
await visible(env.page, "#view-address-token");
|
||||||
|
|
||||||
|
const row = env.page.locator("#address-token-tx-list .tx-row").first();
|
||||||
|
await row.waitFor({ state: "visible", timeout: 30000 });
|
||||||
|
await row.click();
|
||||||
|
await visible(env.page, "#view-transaction");
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"viewStack",
|
||||||
|
base.concat("main", "address", "address-token"),
|
||||||
|
"on transaction detail opened from the token screen",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The stack is checked before the screen, so a Back that lands on the
|
||||||
|
// wrong screen fails by saying what the stack holds.
|
||||||
|
await env.page.click("#btn-tx-back");
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"viewStack",
|
||||||
|
base.concat("main", "address"),
|
||||||
|
"after Back from transaction detail",
|
||||||
|
);
|
||||||
|
await visible(env.page, "#view-address-token");
|
||||||
|
|
||||||
|
// Onto the address screen, which the next test starts from.
|
||||||
|
await env.page.click("#btn-address-token-back");
|
||||||
|
await visible(env.page, "#view-address");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read off the anchor rather than followed: where it points is all the popup
|
||||||
|
// decides, and following it would only load the explorer's page. The suite is
|
||||||
|
// on mainnet until the Settings section.
|
||||||
|
test("the token contract row links to the explorer's token page (#151)", async (env) => {
|
||||||
|
await leaveTransactionDetail(env.page);
|
||||||
|
const row = env.page
|
||||||
|
.locator("#tx-list .tx-row")
|
||||||
|
.filter({ hasText: STUB_TOKEN.symbol });
|
||||||
|
await row.waitFor({ state: "visible", timeout: 30000 });
|
||||||
|
await row.click();
|
||||||
|
await visible(env.page, "#view-transaction");
|
||||||
|
await visible(env.page, "#tx-detail-token-contract-section");
|
||||||
|
|
||||||
|
const href = await env.page
|
||||||
|
.locator("#tx-detail-token-contract a")
|
||||||
|
.getAttribute("href");
|
||||||
|
const expected = "https://etherscan.io/token/" + STUB_TOKEN.address;
|
||||||
|
assert(
|
||||||
|
href === expected,
|
||||||
|
"the token contract row links to " +
|
||||||
|
JSON.stringify(href) +
|
||||||
|
", expected " +
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------- reload mid-refresh (#218)
|
||||||
|
//
|
||||||
|
// Reloading or closing the popup makes Chrome cancel the requests it still has
|
||||||
|
// open, and in the page a cancelled fetch() fails with the same "Failed to
|
||||||
|
// fetch" as a server that cannot be reached. Here rather than straight after
|
||||||
|
// wallet creation because a reload also cancels the address scan that follows
|
||||||
|
// it, which still logs (#475).
|
||||||
|
|
||||||
|
// Blockscout is held, so the home screen's transaction list and token balances
|
||||||
|
// cannot have been answered when the popup reloads. The assertion is the
|
||||||
|
// harness's own: a console.error from the page being reloaded fails this test.
|
||||||
|
test("reloading the popup mid-refresh reports no failure (#218)", async (env) => {
|
||||||
|
await goHome(env.page);
|
||||||
|
env.routeOpts.holdBlockscout = true;
|
||||||
|
try {
|
||||||
|
const inFlight = Promise.all([
|
||||||
|
env.page.waitForRequest((r) => r.url().endsWith("/transactions")),
|
||||||
|
env.page.waitForRequest((r) => r.url().endsWith("/token-balances")),
|
||||||
|
]);
|
||||||
|
await env.page.reload();
|
||||||
|
await inFlight;
|
||||||
|
await env.page.reload();
|
||||||
|
} finally {
|
||||||
|
env.routeOpts.holdBlockscout = false;
|
||||||
|
}
|
||||||
|
await visible(env.page, "#view-main");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The same "Failed to fetch" from a server that really cannot be reached is
|
||||||
|
// still reported, so it still fails the run unless a test declares it. Chrome
|
||||||
|
// reports the failed request itself as well, which a cancelled one does not.
|
||||||
|
test("a transaction list that cannot be fetched is still reported (#218)", async (env) => {
|
||||||
|
env.errors.expect(
|
||||||
|
"loadHomeTxs failed",
|
||||||
|
/loadHomeTxs failed: Failed to fetch/,
|
||||||
|
);
|
||||||
|
env.errors.expect(
|
||||||
|
"Chrome's report of the failed request",
|
||||||
|
/Failed to load resource: net::ERR_FAILED/,
|
||||||
|
);
|
||||||
|
env.routeOpts.failTransactionList = true;
|
||||||
|
try {
|
||||||
|
// Drawn again by the next ten-second refresh.
|
||||||
|
await env.page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document
|
||||||
|
.getElementById("home-tx-list")
|
||||||
|
.textContent.includes("Failed to load transactions."),
|
||||||
|
null,
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
env.routeOpts.failTransactionList = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------- recovery phrase (#161)
|
// -------------------------------------------- recovery phrase (#161)
|
||||||
|
|
||||||
// The gear toggles, so pressing it while Settings is already up leaves it.
|
// The gear toggles, so pressing it while Settings is already up leaves it.
|
||||||
@@ -485,22 +762,26 @@ async function openSettings(page) {
|
|||||||
await visible(page, "#view-settings");
|
await visible(page, "#view-settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything the recovery phrase screen is holding, read straight out of
|
// Everything a screen that shows a secret is holding, read straight out of
|
||||||
// the DOM whether or not that screen is the one on top. Reading it while it
|
// the DOM whether or not that screen is the one on top. Reading it while it
|
||||||
// is hidden is the point: "cleared on leave" means the node is empty, not
|
// is hidden is the point: "cleared on leave" means the node is empty, not
|
||||||
// merely off-screen.
|
// merely off-screen. `view` is "show-phrase" or "export-privkey"; the two
|
||||||
async function phraseScreenState(page) {
|
// screens name their elements the same way.
|
||||||
return page.evaluate(() => ({
|
async function secretScreenState(page, view) {
|
||||||
value: document.getElementById("show-phrase-value").textContent,
|
return page.evaluate(
|
||||||
error: document.getElementById("show-phrase-flash").textContent,
|
(v) => ({
|
||||||
html: document.getElementById("view-show-phrase").innerHTML,
|
value: document.getElementById(v + "-value").textContent,
|
||||||
|
error: document.getElementById(v + "-flash").textContent,
|
||||||
|
html: document.getElementById("view-" + v).innerHTML,
|
||||||
resultHidden: document
|
resultHidden: document
|
||||||
.getElementById("show-phrase-result")
|
.getElementById(v + "-result")
|
||||||
.classList.contains("hidden"),
|
.classList.contains("hidden"),
|
||||||
viewHidden: document
|
viewHidden: document
|
||||||
.getElementById("view-show-phrase")
|
.getElementById("view-" + v)
|
||||||
.classList.contains("hidden"),
|
.classList.contains("hidden"),
|
||||||
}));
|
}),
|
||||||
|
view,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPhraseScreen(page) {
|
async function openPhraseScreen(page) {
|
||||||
@@ -515,12 +796,12 @@ async function revealPhrase(page) {
|
|||||||
await visible(page, "#show-phrase-result", 60000);
|
await visible(page, "#show-phrase-result", 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertWiped(st, phrase, where) {
|
function assertWiped(st, secret, where) {
|
||||||
assert(st.value === "", "phrase still in the DOM " + where);
|
assert(st.value === "", "the secret is still in the DOM " + where);
|
||||||
assert(st.resultHidden, "result section still shown " + where);
|
assert(st.resultHidden, "result section still shown " + where);
|
||||||
assert(
|
assert(
|
||||||
!st.html.includes(phrase),
|
!st.html.includes(secret),
|
||||||
"the recovery phrase is still somewhere in the screen markup " + where,
|
"the secret is still somewhere in the screen markup " + where,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,7 +823,8 @@ test("only an HD wallet is offered the recovery phrase action (#161)", async (en
|
|||||||
// The other half of the gate, against the real UI: a wallet holding a bare
|
// The other half of the gate, against the real UI: a wallet holding a bare
|
||||||
// private key has no phrase to show, so no row of it may offer the action.
|
// private key has no phrase to show, so no row of it may offer the action.
|
||||||
// The key is generated here rather than committed — the repo holds no
|
// The key is generated here rather than committed — the repo holds no
|
||||||
// private keys, test ones included.
|
// private keys, test ones included. It is kept on env for the private key
|
||||||
|
// export tests (#253).
|
||||||
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
|
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
|
||||||
const { Wallet } = require("ethers");
|
const { Wallet } = require("ethers");
|
||||||
|
|
||||||
@@ -550,10 +832,8 @@ test("a key wallet is not offered the recovery phrase action (#161)", async (env
|
|||||||
await env.page.click("#btn-main-add-wallet");
|
await env.page.click("#btn-main-add-wallet");
|
||||||
await visible(env.page, "#view-add-wallet");
|
await visible(env.page, "#view-add-wallet");
|
||||||
await env.page.click("#tab-privkey");
|
await env.page.click("#tab-privkey");
|
||||||
await env.page.fill(
|
env.privateKey = Wallet.createRandom().privateKey;
|
||||||
"#import-private-key",
|
await env.page.fill("#import-private-key", env.privateKey);
|
||||||
Wallet.createRandom().privateKey,
|
|
||||||
);
|
|
||||||
await env.page.fill("#add-wallet-password", PASSWORD);
|
await env.page.fill("#add-wallet-password", PASSWORD);
|
||||||
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
|
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
|
||||||
await env.page.click("#btn-add-wallet-confirm");
|
await env.page.click("#btn-add-wallet-confirm");
|
||||||
@@ -575,7 +855,7 @@ test("a key wallet is not offered the recovery phrase action (#161)", async (env
|
|||||||
|
|
||||||
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
|
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
|
||||||
await openPhraseScreen(env.page);
|
await openPhraseScreen(env.page);
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assertWiped(st, env.phrase, "before any password was entered");
|
assertWiped(st, env.phrase, "before any password was entered");
|
||||||
const passwordShown = await env.page.isVisible(
|
const passwordShown = await env.page.isVisible(
|
||||||
"#show-phrase-password-section",
|
"#show-phrase-password-section",
|
||||||
@@ -593,7 +873,7 @@ test("a wrong password reveals nothing (#161)", async (env) => {
|
|||||||
{ timeout: 60000 },
|
{ timeout: 60000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assertWiped(st, env.phrase, "after a wrong password");
|
assertWiped(st, env.phrase, "after a wrong password");
|
||||||
assert(
|
assert(
|
||||||
/^[A-Z].*\.$/.test(st.error.trim()),
|
/^[A-Z].*\.$/.test(st.error.trim()),
|
||||||
@@ -609,7 +889,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
|
|||||||
try {
|
try {
|
||||||
await revealPhrase(env.page);
|
await revealPhrase(env.page);
|
||||||
|
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assert(
|
assert(
|
||||||
st.value === env.phrase,
|
st.value === env.phrase,
|
||||||
"the displayed phrase is not the wallet's phrase, verbatim",
|
"the displayed phrase is not the wallet's phrase, verbatim",
|
||||||
@@ -640,7 +920,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
|
|||||||
test('"Back" wipes the revealed phrase (#161)', async (env) => {
|
test('"Back" wipes the revealed phrase (#161)', async (env) => {
|
||||||
await env.page.click("#btn-show-phrase-back");
|
await env.page.click("#btn-show-phrase-back");
|
||||||
await visible(env.page, "#view-settings");
|
await visible(env.page, "#view-settings");
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
||||||
assertWiped(st, env.phrase, "after Back");
|
assertWiped(st, env.phrase, "after Back");
|
||||||
});
|
});
|
||||||
@@ -652,7 +932,7 @@ test("leaving by the settings gear wipes it too (#161)", async (env) => {
|
|||||||
await revealPhrase(env.page);
|
await revealPhrase(env.page);
|
||||||
await env.page.click("#btn-settings");
|
await env.page.click("#btn-settings");
|
||||||
await visible(env.page, "#view-settings");
|
await visible(env.page, "#view-settings");
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assertWiped(st, env.phrase, "after leaving via the settings gear");
|
assertWiped(st, env.phrase, "after leaving via the settings gear");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -689,7 +969,7 @@ test("leaving while the decrypt is in flight reveals nothing (#161)", async (env
|
|||||||
);
|
);
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
// Printed on every run, pass or fail: "the phrase is not there" is
|
// Printed on every run, pass or fail: "the phrase is not there" is
|
||||||
// worth more as a measurement than as a silent assertion, and the
|
// worth more as a measurement than as a silent assertion, and the
|
||||||
// same line read from a build without the guard is what this test
|
// same line read from a build without the guard is what this test
|
||||||
@@ -726,11 +1006,141 @@ test("reopening the popup never lands on the phrase screen (#161)", async (env)
|
|||||||
env.page = await openPopup(env.ctx, env.popupUrl);
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
||||||
await visible(env.page, "#view-main");
|
await visible(env.page, "#view-main");
|
||||||
|
|
||||||
const st = await phraseScreenState(env.page);
|
const st = await secretScreenState(env.page, "show-phrase");
|
||||||
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
|
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
|
||||||
assertWiped(st, env.phrase, "after reopening the popup");
|
assertWiped(st, env.phrase, "after reopening the popup");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------ private key export (#253)
|
||||||
|
|
||||||
|
// The recovery phrase cases above, on the private key export screen. They run
|
||||||
|
// against the key wallet imported above, not the HD wallet: leaving the screen
|
||||||
|
// drops the address it was showing, and without one an HD wallet's key cannot
|
||||||
|
// be derived, so there a decrypt that finished late would fail on its own and
|
||||||
|
// the liveness check would go untested.
|
||||||
|
|
||||||
|
// From Home to the export screen of the key wallet's one address. The key
|
||||||
|
// wallet is the second wallet in the list.
|
||||||
|
async function openPrivkeyScreen(page) {
|
||||||
|
await visible(page, "#view-main");
|
||||||
|
await page.click('#wallet-list .btn-addr-info[data-wallet="1"]');
|
||||||
|
await visible(page, "#view-address");
|
||||||
|
await page.click("#btn-more-menu");
|
||||||
|
await page.click("#btn-export-privkey");
|
||||||
|
await visible(page, "#view-export-privkey");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revealPrivkey(page) {
|
||||||
|
await page.fill("#export-privkey-password", PASSWORD);
|
||||||
|
await page.click("#btn-export-privkey-confirm");
|
||||||
|
await visible(page, "#export-privkey-result", 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the export screen, or the Settings screen the gear left it for, for
|
||||||
|
// Home. The gear put the export screen on the Back stack, so from Settings the
|
||||||
|
// way home passes through it, already emptied
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
|
||||||
|
async function leavePrivkeyScreen(page) {
|
||||||
|
if (await page.isVisible("#view-settings")) {
|
||||||
|
await page.click("#btn-settings-back");
|
||||||
|
await visible(page, "#view-export-privkey");
|
||||||
|
}
|
||||||
|
if (await page.isVisible("#view-export-privkey")) {
|
||||||
|
await page.click("#btn-export-privkey-back");
|
||||||
|
await visible(page, "#view-address");
|
||||||
|
}
|
||||||
|
if (await page.isVisible("#view-address")) {
|
||||||
|
await page.click("#btn-address-back");
|
||||||
|
}
|
||||||
|
await visible(page, "#view-main");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the correct password reveals the private key, and nothing logs it (#253)", async (env) => {
|
||||||
|
const console_ = [];
|
||||||
|
const listener = (msg) => console_.push(msg.text());
|
||||||
|
env.page.on("console", listener);
|
||||||
|
try {
|
||||||
|
await openPrivkeyScreen(env.page);
|
||||||
|
await revealPrivkey(env.page);
|
||||||
|
|
||||||
|
const st = await secretScreenState(env.page, "export-privkey");
|
||||||
|
assert(
|
||||||
|
st.value === env.privateKey,
|
||||||
|
"the displayed key is not the wallet's private key, verbatim",
|
||||||
|
);
|
||||||
|
const promptShown = await env.page.isVisible(
|
||||||
|
"#export-privkey-password-section",
|
||||||
|
);
|
||||||
|
assert(!promptShown, "the password prompt is still shown after unlock");
|
||||||
|
|
||||||
|
const title = await env.page.getAttribute(
|
||||||
|
"#export-privkey-value",
|
||||||
|
"title",
|
||||||
|
);
|
||||||
|
assert(title === "Click to copy", "the key is not click-to-copy");
|
||||||
|
|
||||||
|
const leaked = console_.filter((line) => line.includes(env.privateKey));
|
||||||
|
assert(
|
||||||
|
leaked.length === 0,
|
||||||
|
"the private key reached the console: " + JSON.stringify(leaked),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
env.page.off("console", listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaving by the settings gear wipes the private key (#253)", async (env) => {
|
||||||
|
await visible(env.page, "#export-privkey-result");
|
||||||
|
await env.page.click("#btn-settings");
|
||||||
|
await visible(env.page, "#view-settings");
|
||||||
|
const st = await secretScreenState(env.page, "export-privkey");
|
||||||
|
assertWiped(st, env.privateKey, "after leaving via the settings gear");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The same interleaving as the recovery phrase case above, and for the same
|
||||||
|
// reason: both clicks in one page task, so the leave and its wipe run while
|
||||||
|
// the decrypt is still awaited. Reveal stays disabled while the decrypt runs,
|
||||||
|
// so reading it after the gear click shows the leave really came mid-decrypt.
|
||||||
|
test("leaving while the decrypt is in flight reveals no private key (#253)", async (env) => {
|
||||||
|
try {
|
||||||
|
await leavePrivkeyScreen(env.page);
|
||||||
|
// The export screen cannot yet be opened twice in one popup session
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/460), so this second
|
||||||
|
// open gets a fresh one.
|
||||||
|
await reopenPopup(env, "main");
|
||||||
|
await openPrivkeyScreen(env.page);
|
||||||
|
await env.page.fill("#export-privkey-password", PASSWORD);
|
||||||
|
const inFlight = await env.page.evaluate(() => {
|
||||||
|
const reveal = document.getElementById(
|
||||||
|
"btn-export-privkey-confirm",
|
||||||
|
);
|
||||||
|
reveal.click();
|
||||||
|
document.getElementById("btn-settings").click();
|
||||||
|
return reveal.disabled;
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
inFlight,
|
||||||
|
"the decrypt was not running when the screen was left",
|
||||||
|
);
|
||||||
|
await visible(env.page, "#view-settings");
|
||||||
|
|
||||||
|
// Reveal is re-enabled in the same continuation that would have
|
||||||
|
// written the key, so once it is back the decrypt has finished.
|
||||||
|
await env.page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
!document.getElementById("btn-export-privkey-confirm").disabled,
|
||||||
|
null,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const st = await secretScreenState(env.page, "export-privkey");
|
||||||
|
assert(st.viewHidden, "the private key screen is still on top");
|
||||||
|
assertWiped(st, env.privateKey, "after leaving mid-decrypt");
|
||||||
|
} finally {
|
||||||
|
await leavePrivkeyScreen(env.page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ------------------------------- Back after reopening the popup (#268)
|
// ------------------------------- Back after reopening the popup (#268)
|
||||||
|
|
||||||
// A reopened popup renders the wallet list and the view it restores onto,
|
// A reopened popup renders the wallet list and the view it restores onto,
|
||||||
@@ -1753,10 +2163,9 @@ function quantity(wei) {
|
|||||||
|
|
||||||
// Wait on the main view until a changed balance fixture has been picked up.
|
// Wait on the main view until a changed balance fixture has been picked up.
|
||||||
//
|
//
|
||||||
// Deliberately not a reload: the popup re-refreshes on a 10-second timer by
|
// Not a reload: the popup re-refreshes on a 10-second timer by itself, and a
|
||||||
// itself, and reloading aborts whatever fetch the home screen has open at
|
// reload on the address screen still logs the transaction list it cancels
|
||||||
// that instant, which the extension reports through log.errorf and the
|
// (#475).
|
||||||
// harness — correctly — fails the run on.
|
|
||||||
//
|
//
|
||||||
// It also deliberately settles on MAIN rather than on the address screen.
|
// It also deliberately settles on MAIN rather than on the address screen.
|
||||||
// The address screen builds the send screen's token dropdown once, from the
|
// The address screen builds the send screen's token dropdown once, from the
|
||||||
@@ -2445,6 +2854,84 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------ the wait for a receipt ending in error (#315)
|
||||||
|
//
|
||||||
|
// README.md (WaitTx) documents two ways the wait ends on the error screen: a
|
||||||
|
// lookup that answers "no receipt" 60 seconds or more after the broadcast, and
|
||||||
|
// six lookups in a row that fail. They are different facts with different
|
||||||
|
// messages, so each is driven to its own.
|
||||||
|
//
|
||||||
|
// Both wait in real time, about a minute each. The wait reads the popup's own
|
||||||
|
// clock and its own ten-second timer. Playwright's clock would move both, but
|
||||||
|
// it is installed on the whole browser context and cannot be removed, so every
|
||||||
|
// later test would run on it. Moving the stored broadcast time back instead can
|
||||||
|
// be undone by the save the popup makes every ten seconds.
|
||||||
|
|
||||||
|
// Send ETH from the address screen and stop on the wait for its receipt.
|
||||||
|
async function sendEthToWait(env) {
|
||||||
|
await goToConfirm(env.page, {
|
||||||
|
token: "ETH",
|
||||||
|
balance: FUNDED_ETH_TEXT + " ETH",
|
||||||
|
amount: COMFORTABLE_AMOUNT,
|
||||||
|
});
|
||||||
|
await waitForEstimate(env.page);
|
||||||
|
await fillPasswordAndSend(env.page);
|
||||||
|
await visible(env.page, "#view-wait-tx", 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a wait still without a receipt after 60 seconds ends on the timeout message (#315)", async (env) => {
|
||||||
|
try {
|
||||||
|
await sendEthToWait(env);
|
||||||
|
// Lookups run every ten seconds and answer "no receipt", so the one
|
||||||
|
// that ends the wait comes about 60 seconds after the broadcast.
|
||||||
|
await visible(env.page, "#view-error-tx", 90000);
|
||||||
|
const message = (
|
||||||
|
await env.page.locator("#error-tx-message").innerText()
|
||||||
|
).trim();
|
||||||
|
assert(
|
||||||
|
message ===
|
||||||
|
"Transaction was not confirmed within 60 seconds. It may still confirm later — check Etherscan.",
|
||||||
|
"the wait did not end on the timeout message: " +
|
||||||
|
JSON.stringify(message),
|
||||||
|
);
|
||||||
|
await env.page.click("#btn-error-tx-done");
|
||||||
|
await visible(env.page, "#view-address");
|
||||||
|
} finally {
|
||||||
|
await backToAddressAfterSend(env);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("six failed receipt lookups in a row end on the unreachable-network message (#315)", async (env) => {
|
||||||
|
// Each failed lookup is logged through log.errorf, i.e. console.error.
|
||||||
|
// Exactly six are declared: a wait that ended sooner leaves one unmatched,
|
||||||
|
// and one that went on logs a seventh, and either fails this test.
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
env.errors.expect(
|
||||||
|
"failed receipt lookup " + i + " of 6",
|
||||||
|
/poll receipt failed/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
env.routeOpts.failReceiptLookup = true;
|
||||||
|
try {
|
||||||
|
await sendEthToWait(env);
|
||||||
|
await visible(env.page, "#view-error-tx", 90000);
|
||||||
|
const message = (
|
||||||
|
await env.page.locator("#error-tx-message").innerText()
|
||||||
|
).trim();
|
||||||
|
assert(
|
||||||
|
message ===
|
||||||
|
"The network could not be reached to check this transaction — 6 lookups failed in a row. Check the RPC URL in Settings. The transaction may still have confirmed — check Etherscan.",
|
||||||
|
"the wait did not end on the unreachable-network message: " +
|
||||||
|
JSON.stringify(message),
|
||||||
|
);
|
||||||
|
await env.page.click("#btn-error-tx-done");
|
||||||
|
await visible(env.page, "#view-address");
|
||||||
|
} finally {
|
||||||
|
env.routeOpts.failReceiptLookup = false;
|
||||||
|
await backToAddressAfterSend(env);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ------------------------------------------- hostile token symbol (#307)
|
// ------------------------------------------- hostile token symbol (#307)
|
||||||
//
|
//
|
||||||
// The reproduction from the issue, in the real browser against the real
|
// The reproduction from the issue, in the real browser against the real
|
||||||
@@ -4038,6 +4525,10 @@ async function main() {
|
|||||||
ethBalanceWei: null,
|
ethBalanceWei: null,
|
||||||
failGasEstimate: false,
|
failGasEstimate: false,
|
||||||
holdGasEstimate: false,
|
holdGasEstimate: false,
|
||||||
|
// Whether Blockscout requests are held unanswered, and whether an
|
||||||
|
// address's transaction list fails as a network error (#218).
|
||||||
|
holdBlockscout: false,
|
||||||
|
failTransactionList: false,
|
||||||
// What decimals() answers for the stub token, when it is to answer
|
// What decimals() answers for the stub token, when it is to answer
|
||||||
// something other than the value the same fixture reports through
|
// something other than the value the same fixture reports through
|
||||||
// Blockscout. The token that lies about its scale (#305).
|
// Blockscout. The token that lies about its scale (#305).
|
||||||
@@ -4048,6 +4539,8 @@ async function main() {
|
|||||||
// Whether eth_getTransactionReceipt confirms a transaction rather than
|
// Whether eth_getTransactionReceipt confirms a transaction rather than
|
||||||
// answering "not mined yet".
|
// answering "not mined yet".
|
||||||
seedReceipt: false,
|
seedReceipt: false,
|
||||||
|
// Whether eth_getTransactionReceipt fails instead of answering (#315).
|
||||||
|
failReceiptLookup: false,
|
||||||
// Every raw signed transaction handed to eth_sendRawTransaction, in
|
// Every raw signed transaction handed to eth_sendRawTransaction, in
|
||||||
// order. The dApp transaction round trip asserts against these bytes
|
// order. The dApp transaction round trip asserts against these bytes
|
||||||
// rather than against anything the extension reported about them.
|
// rather than against anything the extension reported about them.
|
||||||
@@ -4080,6 +4573,9 @@ async function main() {
|
|||||||
// The recovery phrase of the wallet created in test 2, so later
|
// The recovery phrase of the wallet created in test 2, so later
|
||||||
// tests can assert on the real secret rather than its shape.
|
// tests can assert on the real secret rather than its shape.
|
||||||
phrase: null,
|
phrase: null,
|
||||||
|
// The private key of the key wallet imported by the recovery phrase
|
||||||
|
// tests (#161), asserted on by the private key export tests (#253).
|
||||||
|
privateKey: null,
|
||||||
// What the Settings section (#229) actually observed. A guard test
|
// What the Settings section (#229) actually observed. A guard test
|
||||||
// at the end of that section demands the full set, so a skipped or
|
// at the end of that section demands the full set, so a skipped or
|
||||||
// silently shortened assertion reddens the run instead of shrinking
|
// silently shortened assertion reddens the run instead of shrinking
|
||||||
|
|||||||
@@ -91,6 +91,17 @@ describe("displaySymbol", () => {
|
|||||||
expect(displaySymbol(exact)).toBe(exact);
|
expect(displaySymbol(exact)).toBe(exact);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An emoji outside the Basic Multilingual Plane is two UTF-16 units.
|
||||||
|
// Cutting between them leaves half of one, which renders as U+FFFD.
|
||||||
|
test("counts an emoji as one character and never cuts one in half", () => {
|
||||||
|
expect(displaySymbol("🚀".repeat(MAX_SYMBOL_LENGTH))).toBe(
|
||||||
|
"🚀".repeat(MAX_SYMBOL_LENGTH),
|
||||||
|
);
|
||||||
|
expect(displaySymbol("🚀".repeat(20))).toBe(
|
||||||
|
"🚀".repeat(MAX_SYMBOL_LENGTH - 1) + "…",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("substitutes a placeholder for an absent symbol", () => {
|
test("substitutes a placeholder for an absent symbol", () => {
|
||||||
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
|
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
|
||||||
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);
|
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);
|
||||||
|
|||||||
@@ -21,15 +21,14 @@
|
|||||||
// escaping in src/shared/html.js is the primary fix; default-src is what
|
// escaping in src/shared/html.js is the primary fix; default-src is what
|
||||||
// stops the next escape that slips from reaching the network.
|
// stops the next escape that slips from reaching the network.
|
||||||
//
|
//
|
||||||
// Every directive below is pinned exactly, because each of the four
|
// And for #328: style-src is 'self' alone, so the browser refuses every
|
||||||
|
// style="..." attribute in the popup's markup, including one an escape lets
|
||||||
|
// through. The popup styles with classes; script setting element.style is
|
||||||
|
// not affected.
|
||||||
|
//
|
||||||
|
// Every directive below is pinned exactly, because each of the three
|
||||||
// loosenings is load-bearing and none of them may grow:
|
// loosenings is load-bearing and none of them may grow:
|
||||||
//
|
//
|
||||||
// style-src 'unsafe-inline' src/popup/index.html and the view helpers
|
|
||||||
// use style="..." attributes throughout, which
|
|
||||||
// CSP blocks without it. Chrome enforces this
|
|
||||||
// on attributes, not just <style> blocks, and
|
|
||||||
// Firefox has never implemented style-src-attr,
|
|
||||||
// so there is no narrower spelling available.
|
|
||||||
// img-src data: blockies are data: PNGs assigned to img.src.
|
// img-src data: blockies are data: PNGs assigned to img.src.
|
||||||
// connect-src https: http: the RPC endpoint is user-configurable, and a
|
// connect-src https: http: the RPC endpoint is user-configurable, and a
|
||||||
// local node over http://127.0.0.1 is a
|
// local node over http://127.0.0.1 is a
|
||||||
@@ -58,7 +57,7 @@ const EXPECTED_DIRECTIVES = {
|
|||||||
"default-src": ["'self'"],
|
"default-src": ["'self'"],
|
||||||
"script-src": ["'self'", "'wasm-unsafe-eval'"],
|
"script-src": ["'self'", "'wasm-unsafe-eval'"],
|
||||||
"object-src": ["'self'"],
|
"object-src": ["'self'"],
|
||||||
"style-src": ["'self'", "'unsafe-inline'"],
|
"style-src": ["'self'"],
|
||||||
"img-src": ["'self'", "data:"],
|
"img-src": ["'self'", "data:"],
|
||||||
"connect-src": ["'self'", "http:", "https:"],
|
"connect-src": ["'self'", "http:", "https:"],
|
||||||
"frame-src": ["'none'"],
|
"frame-src": ["'none'"],
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Every method in PROXY_METHODS is sent to the RPC node.
|
||||||
|
//
|
||||||
|
// handleRpc answers some methods itself before it reaches its proxy branch. A
|
||||||
|
// method listed in PROXY_METHODS but answered earlier never reaches the node,
|
||||||
|
// so the list would name a method that is not proxied
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/326). Each method is sent from
|
||||||
|
// a page here and must come back with what the node answered.
|
||||||
|
|
||||||
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.chrome;
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every method in PROXY_METHODS reaches the RPC node", async () => {
|
||||||
|
jest.resetModules();
|
||||||
|
|
||||||
|
jest.doMock("../src/shared/balances", () => ({
|
||||||
|
getProvider: () => ({}),
|
||||||
|
refreshBalances: jest.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||||
|
isPhishingDomain: () => false,
|
||||||
|
}));
|
||||||
|
jest.doMock("../src/shared/alarms", () => ({
|
||||||
|
BALANCE_REFRESH_ALARM: "balance",
|
||||||
|
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
||||||
|
ensureRecurringAlarms: jest.fn(async () => {}),
|
||||||
|
registerAlarmHandlers: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The node answers each method with a value naming that method.
|
||||||
|
global.fetch = jest.fn(async (url, opts) => ({
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
result: "node answered " + JSON.parse(opts.body).method,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let messageListener = null;
|
||||||
|
global.chrome = {
|
||||||
|
storage: makeStorageStub({
|
||||||
|
autistmask: {
|
||||||
|
networkId: "mainnet",
|
||||||
|
wallets: [],
|
||||||
|
allowedSites: {},
|
||||||
|
deniedSites: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
runtime: {
|
||||||
|
getURL: (path) => "chrome-extension://autistmask/" + path,
|
||||||
|
onMessage: {
|
||||||
|
addListener: (fn) => {
|
||||||
|
messageListener = fn;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onConnect: { addListener: () => {} },
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
windows: { onRemoved: { addListener: () => {} } },
|
||||||
|
action: { setPopup: () => {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { PROXY_METHODS } = require("../src/background/index");
|
||||||
|
|
||||||
|
const answers = {};
|
||||||
|
const expected = {};
|
||||||
|
for (const method of PROXY_METHODS) {
|
||||||
|
messageListener(
|
||||||
|
{ type: "AUTISTMASK_RPC", method, params: [] },
|
||||||
|
{ origin: "https://dapp.example" },
|
||||||
|
(r) => {
|
||||||
|
answers[method] = r;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expected[method] = { result: "node answered " + method };
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(PROXY_METHODS.length).toBeGreaterThan(0);
|
||||||
|
expect(answers).toEqual(expected);
|
||||||
|
});
|
||||||
+294
-2
@@ -4,8 +4,16 @@
|
|||||||
// already in storage: the import that created it ran before the refusal
|
// already in storage: the import that created it ran before the refusal
|
||||||
// existed. Such a wallet used to sign for the wrong tree and now throws on the
|
// existed. Such a wallet used to sign for the wrong tree and now throws on the
|
||||||
// send screen instead. These tests pin down that it is named and explained in
|
// send screen instead. These tests pin down that it is named and explained in
|
||||||
// the wallet list, that nothing on the way there throws, and that a wallet
|
// the wallet list, that every control leading to a signature or to the private
|
||||||
// imported from a real master key is untouched by any of it.
|
// key refuses it before asking for a password, that nothing on the way there
|
||||||
|
// throws, and that a wallet imported from a real master key is untouched by
|
||||||
|
// any of it.
|
||||||
|
|
||||||
|
// Mocked so that no password has to be hashed: the controls below are checked
|
||||||
|
// for whether they decrypt at all.
|
||||||
|
jest.mock("../src/shared/vault", () => ({
|
||||||
|
decryptWithPassword: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const { HDNodeWallet, Mnemonic } = require("ethers");
|
const { HDNodeWallet, Mnemonic } = require("ethers");
|
||||||
|
|
||||||
@@ -237,6 +245,290 @@ describe("the wallet list", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A minimal DOM for driving the popup views: any element exists on first
|
||||||
|
// lookup, and click() runs the listeners a view attached to it.
|
||||||
|
function makeElement(id) {
|
||||||
|
const classes = new Set();
|
||||||
|
const el = {
|
||||||
|
id,
|
||||||
|
textContent: "",
|
||||||
|
title: "",
|
||||||
|
value: "",
|
||||||
|
innerHTML: "",
|
||||||
|
disabled: false,
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
listeners: {},
|
||||||
|
classList: {
|
||||||
|
add: (...names) => names.forEach((n) => classes.add(n)),
|
||||||
|
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
||||||
|
contains: (n) => classes.has(n),
|
||||||
|
toggle: (n, force) => {
|
||||||
|
const on = force === undefined ? !classes.has(n) : force;
|
||||||
|
if (on) classes.add(n);
|
||||||
|
else classes.delete(n);
|
||||||
|
return on;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addEventListener: (name, fn) => {
|
||||||
|
el.listeners[name] = el.listeners[name] || [];
|
||||||
|
el.listeners[name].push(fn);
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
appendChild: () => {},
|
||||||
|
};
|
||||||
|
// Views reach for .parentElement to hide whole sections.
|
||||||
|
Object.defineProperty(el, "parentElement", {
|
||||||
|
get: () => node(id + "-parent"),
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDocument() {
|
||||||
|
const els = new Map();
|
||||||
|
return {
|
||||||
|
getElementById(id) {
|
||||||
|
// The debug banner is created on demand by helpers.js; absent
|
||||||
|
// is the state a non-debug, non-testnet popup is in.
|
||||||
|
if (id === "debug-banner") return null;
|
||||||
|
if (!els.has(id)) els.set(id, makeElement(id));
|
||||||
|
return els.get(id);
|
||||||
|
},
|
||||||
|
createElement: () => makeElement("created"),
|
||||||
|
addEventListener: () => {},
|
||||||
|
body: { prepend: () => {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id) {
|
||||||
|
return globalThis.document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(id) {
|
||||||
|
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSignerForAddress refuses this wallet's key, but only once the password
|
||||||
|
// has been typed and spent, and the screens report that refusal as a wrong
|
||||||
|
// password or a failed send. So every control that leads to it refuses first.
|
||||||
|
describe("every way to a signature or the private key refuses a defective wallet first", () => {
|
||||||
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
|
let state;
|
||||||
|
let decryptWithPassword;
|
||||||
|
let home;
|
||||||
|
let addressDetail;
|
||||||
|
let addressToken;
|
||||||
|
let approval;
|
||||||
|
let confirmTx;
|
||||||
|
|
||||||
|
let address;
|
||||||
|
let shortMessage;
|
||||||
|
// What the background answers when the approval window asks which
|
||||||
|
// approval it was opened for, and every message the popup sent it.
|
||||||
|
let approvalDetails;
|
||||||
|
let sent;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
state = require("../src/shared/state").state;
|
||||||
|
decryptWithPassword =
|
||||||
|
require("../src/shared/vault").decryptWithPassword;
|
||||||
|
home = require("../src/popup/views/home");
|
||||||
|
addressDetail = require("../src/popup/views/addressDetail");
|
||||||
|
addressToken = require("../src/popup/views/addressToken");
|
||||||
|
approval = require("../src/popup/views/approval");
|
||||||
|
confirmTx = require("../src/popup/views/confirmTx");
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const broken = brokenXprvWallet();
|
||||||
|
// A balance, so that no Send button's zero-balance refusal can stand
|
||||||
|
// in for the defect check.
|
||||||
|
broken.addresses[0].balance = "1.0000";
|
||||||
|
broken.addresses[0].tokenBalances = [];
|
||||||
|
address = broken.addresses[0].address;
|
||||||
|
shortMessage = walletDefect(broken).shortMessage;
|
||||||
|
|
||||||
|
approvalDetails = null;
|
||||||
|
sent = [];
|
||||||
|
globalThis.document = makeDocument();
|
||||||
|
globalThis.window = { close: () => {} };
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||||
|
runtime: {
|
||||||
|
connect: () => ({ postMessage: () => {} }),
|
||||||
|
sendMessage: (msg, reply) => {
|
||||||
|
sent.push(msg);
|
||||||
|
if (!reply) return;
|
||||||
|
reply(
|
||||||
|
msg.type === "AUTISTMASK_GET_APPROVAL"
|
||||||
|
? approvalDetails
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// What the wallet's stored secret decrypts to: the account-level key
|
||||||
|
// it was imported from.
|
||||||
|
decryptWithPassword.mockReset();
|
||||||
|
decryptWithPassword.mockResolvedValue(accountXprv(VECTOR_PHRASE));
|
||||||
|
|
||||||
|
state.wallets = [broken];
|
||||||
|
state.activeAddress = address;
|
||||||
|
state.selectedWallet = 0;
|
||||||
|
state.selectedAddress = 0;
|
||||||
|
state.selectedToken = "ETH";
|
||||||
|
state.viewStack = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
state.wallets = [];
|
||||||
|
state.activeAddress = null;
|
||||||
|
state.selectedWallet = null;
|
||||||
|
state.selectedAddress = null;
|
||||||
|
state.selectedToken = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Send on the main screen", async () => {
|
||||||
|
state.currentView = "main";
|
||||||
|
home.init({});
|
||||||
|
|
||||||
|
await click("btn-main-send");
|
||||||
|
|
||||||
|
expect(node("flash-msg").textContent).toBe(shortMessage);
|
||||||
|
expect(state.currentView).toBe("main");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Send on the address screen", async () => {
|
||||||
|
state.currentView = "address";
|
||||||
|
addressDetail.init({});
|
||||||
|
|
||||||
|
await click("btn-send");
|
||||||
|
|
||||||
|
expect(node("flash-msg").textContent).toBe(shortMessage);
|
||||||
|
expect(state.currentView).toBe("address");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Export Private Key on the address screen", async () => {
|
||||||
|
state.currentView = "address";
|
||||||
|
addressDetail.init({});
|
||||||
|
|
||||||
|
await click("btn-export-privkey");
|
||||||
|
|
||||||
|
expect(node("flash-msg").textContent).toBe(shortMessage);
|
||||||
|
expect(state.currentView).toBe("address");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Send on a token's screen", async () => {
|
||||||
|
state.currentView = "address-token";
|
||||||
|
addressToken.init({});
|
||||||
|
|
||||||
|
await click("btn-address-token-send");
|
||||||
|
|
||||||
|
expect(node("flash-msg").textContent).toBe(shortMessage);
|
||||||
|
expect(state.currentView).toBe("address-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The popup reopens onto this screen from a saved view, so the Send
|
||||||
|
// buttons above are not the only way onto it. The screen is not drawn,
|
||||||
|
// because drawing it starts a fee estimate against the network; with a
|
||||||
|
// decrypt that fails, a handler without the check stops at the password
|
||||||
|
// instead of going on to a transaction that was never set up.
|
||||||
|
test("Send on the confirmation screen", async () => {
|
||||||
|
decryptWithPassword.mockRejectedValue(new Error("wrong password"));
|
||||||
|
state.currentView = "confirm-tx";
|
||||||
|
confirmTx.init({});
|
||||||
|
node("confirm-tx-password").value = "any password";
|
||||||
|
|
||||||
|
await click("btn-confirm-send");
|
||||||
|
|
||||||
|
expect(decryptWithPassword).not.toHaveBeenCalled();
|
||||||
|
expect(node("confirm-tx-password-error").textContent).toBe(
|
||||||
|
shortMessage,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openTxApproval() {
|
||||||
|
approvalDetails = {
|
||||||
|
type: "tx",
|
||||||
|
origin: "https://dapp.example",
|
||||||
|
isPhishingDomain: false,
|
||||||
|
approvedFrom: address,
|
||||||
|
approvedTx: {
|
||||||
|
from: address,
|
||||||
|
to: RECIPIENT,
|
||||||
|
value: "0x0",
|
||||||
|
data: "0x",
|
||||||
|
chainId: 1,
|
||||||
|
nonce: 0,
|
||||||
|
gasLimit: "21000",
|
||||||
|
maxFeePerGas: "1000000000",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
approval.init({});
|
||||||
|
await approval.show(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSignApproval() {
|
||||||
|
approvalDetails = {
|
||||||
|
type: "sign",
|
||||||
|
origin: "https://dapp.example",
|
||||||
|
isPhishingDomain: false,
|
||||||
|
approvedFrom: address,
|
||||||
|
// "Hello", as the hex a page sends.
|
||||||
|
signParams: {
|
||||||
|
method: "personal_sign",
|
||||||
|
message: "0x48656c6c6f",
|
||||||
|
from: address,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
approval.init({});
|
||||||
|
await approval.show(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the transaction approval screen says so and disables Approve", async () => {
|
||||||
|
await openTxApproval();
|
||||||
|
|
||||||
|
expect(node("approve-tx-error").textContent).toBe(shortMessage);
|
||||||
|
expect(node("btn-approve-tx").disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The stub runs a disabled button's listener, which a browser would not:
|
||||||
|
// what is asked here is whether the handler refuses on its own.
|
||||||
|
test("Approve on the transaction approval screen does not decrypt", async () => {
|
||||||
|
await openTxApproval();
|
||||||
|
node("approve-tx-password").value = "any password";
|
||||||
|
|
||||||
|
await click("btn-approve-tx");
|
||||||
|
|
||||||
|
expect(decryptWithPassword).not.toHaveBeenCalled();
|
||||||
|
expect(sent.map((msg) => msg.type)).not.toContain(
|
||||||
|
"AUTISTMASK_TX_RESPONSE",
|
||||||
|
);
|
||||||
|
expect(node("approve-tx-error").textContent).toBe(shortMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the signature approval screen says so and disables Approve", async () => {
|
||||||
|
await openSignApproval();
|
||||||
|
|
||||||
|
expect(node("approve-sign-error").textContent).toBe(shortMessage);
|
||||||
|
expect(node("btn-approve-sign").disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Approve on the signature approval screen does not decrypt", async () => {
|
||||||
|
await openSignApproval();
|
||||||
|
node("approve-sign-password").value = "any password";
|
||||||
|
|
||||||
|
await click("btn-approve-sign");
|
||||||
|
|
||||||
|
expect(decryptWithPassword).not.toHaveBeenCalled();
|
||||||
|
expect(sent.map((msg) => msg.type)).not.toContain(
|
||||||
|
"AUTISTMASK_SIGN_RESPONSE",
|
||||||
|
);
|
||||||
|
expect(node("approve-sign-error").textContent).toBe(shortMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("no path throws an unhandled error for a defective wallet", () => {
|
describe("no path throws an unhandled error for a defective wallet", () => {
|
||||||
test("address derivation from the stored xpub still works", () => {
|
test("address derivation from the stored xpub still works", () => {
|
||||||
// The stored xpub is at a non-standard depth but is a valid extended
|
// The stored xpub is at a non-standard depth but is a valid extended
|
||||||
|
|||||||
@@ -275,7 +275,15 @@
|
|||||||
resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz"
|
resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz"
|
||||||
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
||||||
|
|
||||||
"@emnapi/core@^1.4.3", "@emnapi/core@^1.7.1", "@emnapi/core@^1.8.1":
|
"@emnapi/core@^1.10.0":
|
||||||
|
version "1.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.3.tgz#5e95348a42cd1e06f0b9aa380cd74091daa4d520"
|
||||||
|
integrity sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==
|
||||||
|
dependencies:
|
||||||
|
"@emnapi/wasi-threads" "1.2.3"
|
||||||
|
tslib "^2.4.0"
|
||||||
|
|
||||||
|
"@emnapi/core@^1.4.3":
|
||||||
version "1.8.1"
|
version "1.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349"
|
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349"
|
||||||
integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==
|
integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==
|
||||||
@@ -283,20 +291,34 @@
|
|||||||
"@emnapi/wasi-threads" "1.1.0"
|
"@emnapi/wasi-threads" "1.1.0"
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
"@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.1", "@emnapi/runtime@^1.8.1":
|
"@emnapi/runtime@^1.10.0":
|
||||||
|
version "1.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.3.tgz#84257ae3b0531eb2aec1ffa23d70700da007ba95"
|
||||||
|
integrity sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.4.0"
|
||||||
|
|
||||||
|
"@emnapi/runtime@^1.4.3":
|
||||||
version "1.8.1"
|
version "1.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5"
|
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5"
|
||||||
integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==
|
integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.1.0":
|
"@emnapi/wasi-threads@1.1.0":
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz"
|
resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz"
|
||||||
integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==
|
integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
|
"@emnapi/wasi-threads@1.2.3", "@emnapi/wasi-threads@^1.2.1":
|
||||||
|
version "1.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz#c9bf72fd4be5b928aee894820e8d814ed73916e9"
|
||||||
|
integrity sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.4.0"
|
||||||
|
|
||||||
"@esbuild/aix-ppc64@0.27.3":
|
"@esbuild/aix-ppc64@0.27.3":
|
||||||
version "0.27.3"
|
version "0.27.3"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2"
|
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2"
|
||||||
@@ -802,14 +824,12 @@
|
|||||||
"@emnapi/runtime" "^1.4.3"
|
"@emnapi/runtime" "^1.4.3"
|
||||||
"@tybys/wasm-util" "^0.10.0"
|
"@tybys/wasm-util" "^0.10.0"
|
||||||
|
|
||||||
"@napi-rs/wasm-runtime@^1.1.1":
|
"@napi-rs/wasm-runtime@^1.1.4":
|
||||||
version "1.1.1"
|
version "1.2.5"
|
||||||
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz#c3705ab549d176b8dc5172723d6156c3dc426af2"
|
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz#8c728c07e2543d80e55ccb34158c4ae9d6bd13e8"
|
||||||
integrity sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==
|
integrity sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@emnapi/core" "^1.7.1"
|
"@tybys/wasm-util" "^0.10.3"
|
||||||
"@emnapi/runtime" "^1.7.1"
|
|
||||||
"@tybys/wasm-util" "^0.10.1"
|
|
||||||
|
|
||||||
"@noble/curves@1.2.0":
|
"@noble/curves@1.2.0":
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
@@ -823,94 +843,94 @@
|
|||||||
resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz"
|
resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz"
|
||||||
integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==
|
integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==
|
||||||
|
|
||||||
"@parcel/watcher-android-arm64@2.5.6":
|
"@parcel/watcher-android-arm64@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz#5f32e0dba356f4ac9a11068d2a5c134ca3ba6564"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1"
|
||||||
integrity sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==
|
integrity sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==
|
||||||
|
|
||||||
"@parcel/watcher-darwin-arm64@2.5.6":
|
"@parcel/watcher-darwin-arm64@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz#3d26dce38de6590ef79c47ec2c55793c06ad4f67"
|
||||||
integrity sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==
|
integrity sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==
|
||||||
|
|
||||||
"@parcel/watcher-darwin-x64@2.5.6":
|
"@parcel/watcher-darwin-x64@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz#bf05d76a78bc15974f15ec3671848698b0838063"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz#99f3af3869069ccf774e4ddfccf7e64fd2311ef8"
|
||||||
integrity sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==
|
integrity sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==
|
||||||
|
|
||||||
"@parcel/watcher-freebsd-x64@2.5.6":
|
"@parcel/watcher-freebsd-x64@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz#8bc26e9848e7303ac82922a5ae1b1ef1bdb48a53"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz#14d6857741a9f51dfe51d5b08b7c8afdbc73ad9b"
|
||||||
integrity sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==
|
integrity sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==
|
||||||
|
|
||||||
"@parcel/watcher-linux-arm-glibc@2.5.6":
|
"@parcel/watcher-linux-arm-glibc@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz#1328fee1deb0c2d7865079ef53a2ba4cc2f8b40a"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz#43c3246d6892381db473bb4f663229ad20b609a1"
|
||||||
integrity sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==
|
integrity sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==
|
||||||
|
|
||||||
"@parcel/watcher-linux-arm-musl@2.5.6":
|
"@parcel/watcher-linux-arm-musl@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz#bad0f45cb3e2157746db8b9d22db6a125711f152"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz#663750f7090bb6278d2210de643eb8a3f780d08e"
|
||||||
integrity sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==
|
integrity sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==
|
||||||
|
|
||||||
"@parcel/watcher-linux-arm64-glibc@2.5.6":
|
"@parcel/watcher-linux-arm64-glibc@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz#b75913fbd501d9523c5f35d420957bf7d0204809"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz#ba60e1f56977f7e47cd7e31ad65d15fdcbd07e30"
|
||||||
integrity sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==
|
integrity sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==
|
||||||
|
|
||||||
"@parcel/watcher-linux-arm64-musl@2.5.6":
|
"@parcel/watcher-linux-arm64-musl@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz#da5621a6a576070c8c0de60dea8b46dc9c3827d4"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz#f7fbcdff2f04c526f96eac01f97419a6a99855d2"
|
||||||
integrity sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==
|
integrity sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==
|
||||||
|
|
||||||
"@parcel/watcher-linux-x64-glibc@2.5.6":
|
"@parcel/watcher-linux-x64-glibc@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz#ce437accdc4b30f93a090b4a221fd95cd9b89639"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz#4d2ea0f633eb1917d83d483392ce6181b6a92e4e"
|
||||||
integrity sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==
|
integrity sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==
|
||||||
|
|
||||||
"@parcel/watcher-linux-x64-musl@2.5.6":
|
"@parcel/watcher-linux-x64-musl@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz#02400c54b4a67efcc7e2327b249711920ac969e2"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz#277b346b05db54f55657301dd77bdf99d63606ee"
|
||||||
integrity sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==
|
integrity sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==
|
||||||
|
|
||||||
"@parcel/watcher-win32-arm64@2.5.6":
|
"@parcel/watcher-win32-arm64@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz#caae3d3c7583ca0a7171e6bd142c34d20ea1691e"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz#7e9e02a26784d47503de1d10e8eab6cceb524243"
|
||||||
integrity sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==
|
integrity sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==
|
||||||
|
|
||||||
"@parcel/watcher-win32-ia32@2.5.6":
|
"@parcel/watcher-win32-ia32@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz#9ac922550896dfe47bfc5ae3be4f1bcaf8155d6d"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz#2d0f94fa59a873cdc584bf7f6b1dc628ddf976e6"
|
||||||
integrity sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==
|
integrity sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==
|
||||||
|
|
||||||
"@parcel/watcher-win32-x64@2.5.6":
|
"@parcel/watcher-win32-x64@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz#73fdafba2e21c448f0e456bbe13178d8fe11739d"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz#ae52693259664ba6f2228fa61d7ee44b64ea0947"
|
||||||
integrity sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==
|
integrity sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==
|
||||||
|
|
||||||
"@parcel/watcher@^2.5.1":
|
"@parcel/watcher@2.5.1":
|
||||||
version "2.5.6"
|
version "2.5.1"
|
||||||
resolved "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz"
|
resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.1.tgz#342507a9cfaaf172479a882309def1e991fb1200"
|
||||||
integrity sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==
|
integrity sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==
|
||||||
dependencies:
|
dependencies:
|
||||||
detect-libc "^2.0.3"
|
detect-libc "^1.0.3"
|
||||||
is-glob "^4.0.3"
|
is-glob "^4.0.3"
|
||||||
|
micromatch "^4.0.5"
|
||||||
node-addon-api "^7.0.0"
|
node-addon-api "^7.0.0"
|
||||||
picomatch "^4.0.3"
|
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@parcel/watcher-android-arm64" "2.5.6"
|
"@parcel/watcher-android-arm64" "2.5.1"
|
||||||
"@parcel/watcher-darwin-arm64" "2.5.6"
|
"@parcel/watcher-darwin-arm64" "2.5.1"
|
||||||
"@parcel/watcher-darwin-x64" "2.5.6"
|
"@parcel/watcher-darwin-x64" "2.5.1"
|
||||||
"@parcel/watcher-freebsd-x64" "2.5.6"
|
"@parcel/watcher-freebsd-x64" "2.5.1"
|
||||||
"@parcel/watcher-linux-arm-glibc" "2.5.6"
|
"@parcel/watcher-linux-arm-glibc" "2.5.1"
|
||||||
"@parcel/watcher-linux-arm-musl" "2.5.6"
|
"@parcel/watcher-linux-arm-musl" "2.5.1"
|
||||||
"@parcel/watcher-linux-arm64-glibc" "2.5.6"
|
"@parcel/watcher-linux-arm64-glibc" "2.5.1"
|
||||||
"@parcel/watcher-linux-arm64-musl" "2.5.6"
|
"@parcel/watcher-linux-arm64-musl" "2.5.1"
|
||||||
"@parcel/watcher-linux-x64-glibc" "2.5.6"
|
"@parcel/watcher-linux-x64-glibc" "2.5.1"
|
||||||
"@parcel/watcher-linux-x64-musl" "2.5.6"
|
"@parcel/watcher-linux-x64-musl" "2.5.1"
|
||||||
"@parcel/watcher-win32-arm64" "2.5.6"
|
"@parcel/watcher-win32-arm64" "2.5.1"
|
||||||
"@parcel/watcher-win32-ia32" "2.5.6"
|
"@parcel/watcher-win32-ia32" "2.5.1"
|
||||||
"@parcel/watcher-win32-x64" "2.5.6"
|
"@parcel/watcher-win32-x64" "2.5.1"
|
||||||
|
|
||||||
"@pkgjs/parseargs@^0.11.0":
|
"@pkgjs/parseargs@^0.11.0":
|
||||||
version "0.11.0"
|
version "0.11.0"
|
||||||
@@ -941,124 +961,131 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@sinonjs/commons" "^3.0.1"
|
"@sinonjs/commons" "^3.0.1"
|
||||||
|
|
||||||
"@tailwindcss/cli@^4.2.1":
|
"@tailwindcss/cli@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.1.tgz"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/cli/-/cli-4.3.1.tgz#bc00e49e2b70baad223969071e4e380da7123afe"
|
||||||
integrity sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==
|
integrity sha512-ZWPy20rF+TBfTImxDMG3Wr75Y3RpaPlo9lc+oJbInlMyjT+XPkTVKVIL5RZ7JirXuIahcfHoLNFRmDorKi+JQQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@parcel/watcher" "^2.5.1"
|
"@parcel/watcher" "2.5.1"
|
||||||
"@tailwindcss/node" "4.2.1"
|
"@tailwindcss/node" "4.3.1"
|
||||||
"@tailwindcss/oxide" "4.2.1"
|
"@tailwindcss/oxide" "4.3.1"
|
||||||
enhanced-resolve "^5.19.0"
|
enhanced-resolve "5.21.6"
|
||||||
mri "^1.2.0"
|
mri "^1.2.0"
|
||||||
picocolors "^1.1.1"
|
picocolors "^1.1.1"
|
||||||
tailwindcss "4.2.1"
|
tailwindcss "4.3.1"
|
||||||
|
|
||||||
"@tailwindcss/node@4.2.1":
|
"@tailwindcss/node@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.3.1.tgz#77402afcfa29c4b48b8494d0edfc4428d0a504ba"
|
||||||
integrity sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==
|
integrity sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@jridgewell/remapping" "^2.3.5"
|
"@jridgewell/remapping" "^2.3.5"
|
||||||
enhanced-resolve "^5.19.0"
|
enhanced-resolve "5.21.6"
|
||||||
jiti "^2.6.1"
|
jiti "^2.7.0"
|
||||||
lightningcss "1.31.1"
|
lightningcss "1.32.0"
|
||||||
magic-string "^0.30.21"
|
magic-string "^0.30.21"
|
||||||
source-map-js "^1.2.1"
|
source-map-js "^1.2.1"
|
||||||
tailwindcss "4.2.1"
|
tailwindcss "4.3.1"
|
||||||
|
|
||||||
"@tailwindcss/oxide-android-arm64@4.2.1":
|
"@tailwindcss/oxide-android-arm64@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz#a7c24919b607e7f884e6ab97799d12c7fb5b47bd"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz#83c6762cd383a2ebc6e01897b0f35f19225e6653"
|
||||||
integrity sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==
|
integrity sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-arm64@4.2.1":
|
"@tailwindcss/oxide-darwin-arm64@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz#2558b7e835889ad721823e4dcb50dd5071d747d8"
|
||||||
integrity sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==
|
integrity sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-x64@4.2.1":
|
"@tailwindcss/oxide-darwin-x64@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz#1e59ef0665f6cb9e658bf0ebcb3cb50f21b2c175"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz#d987957b87a26668b6d0117ccd4a8a4d1a318a2b"
|
||||||
integrity sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==
|
integrity sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==
|
||||||
|
|
||||||
"@tailwindcss/oxide-freebsd-x64@4.2.1":
|
"@tailwindcss/oxide-freebsd-x64@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz#6b0c75e9dac7f1a241cb9a5eaa89f0d9664835b6"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz#75b342c81a07b1afa437976ec82f86d372431da7"
|
||||||
integrity sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==
|
integrity sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1":
|
"@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz#717044d8fe746b1f0760485946c0c9a900174f7b"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz#6730adc6d17187eeeff2f14f6a914d009749cb97"
|
||||||
integrity sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==
|
integrity sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu@4.2.1":
|
"@tailwindcss/oxide-linux-arm64-gnu@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz#f544b0faf166d80791347911b2dd4372a893129d"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz#869d16b3d9bd8097b797a3dd876db0368c07eae3"
|
||||||
integrity sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==
|
integrity sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-musl@4.2.1":
|
"@tailwindcss/oxide-linux-arm64-musl@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz#9fbaf8dc00b858a2b955526abb15d88f5678d1ef"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz#ab110680ce3c7a2a135656db4402dffc1fb9c1d7"
|
||||||
integrity sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==
|
integrity sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-gnu@4.2.1":
|
"@tailwindcss/oxide-linux-x64-gnu@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz#6ab4e6b8d308d037a1155b8443df5941dbfa6aa1"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz#422a4175a76ae60dd9d17946eec3584cb636352f"
|
||||||
integrity sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==
|
integrity sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-musl@4.2.1":
|
"@tailwindcss/oxide-linux-x64-musl@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz#52c55593394dff85f1fa88172f69f8fdcde182b6"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz#f4c714a653a0e742955d2af2c53d0064b4c500d1"
|
||||||
integrity sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==
|
integrity sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi@4.2.1":
|
"@tailwindcss/oxide-wasm32-wasi@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz#7401e35f881d3654b6180badd1243d75a2702ea5"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz#32172ca8b2427b9c2bb09c97756960185b7d4fc0"
|
||||||
integrity sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==
|
integrity sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@emnapi/core" "^1.8.1"
|
"@emnapi/core" "^1.10.0"
|
||||||
"@emnapi/runtime" "^1.8.1"
|
"@emnapi/runtime" "^1.10.0"
|
||||||
"@emnapi/wasi-threads" "^1.1.0"
|
"@emnapi/wasi-threads" "^1.2.1"
|
||||||
"@napi-rs/wasm-runtime" "^1.1.1"
|
"@napi-rs/wasm-runtime" "^1.1.4"
|
||||||
"@tybys/wasm-util" "^0.10.1"
|
"@tybys/wasm-util" "^0.10.2"
|
||||||
tslib "^2.8.1"
|
tslib "^2.8.1"
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc@4.2.1":
|
"@tailwindcss/oxide-win32-arm64-msvc@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz#63a502e7b696dcd976aa356b94ce0f4f8f832c44"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz#07a11b6eb1f578d012460e6ad6f2352a28d32514"
|
||||||
integrity sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==
|
integrity sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc@4.2.1":
|
"@tailwindcss/oxide-win32-x64-msvc@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz#8cc59b28ebc4dc866c0c14d7057f07f0ed04c4a8"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz#60c6095d97b141c02de36bb52a16c358d9bdaa98"
|
||||||
integrity sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==
|
integrity sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==
|
||||||
|
|
||||||
"@tailwindcss/oxide@4.2.1":
|
"@tailwindcss/oxide@4.3.1":
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide/-/oxide-4.3.1.tgz#6fdd28b3abf785e2c2cac31f52c4755875826828"
|
||||||
integrity sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==
|
integrity sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@tailwindcss/oxide-android-arm64" "4.2.1"
|
"@tailwindcss/oxide-android-arm64" "4.3.1"
|
||||||
"@tailwindcss/oxide-darwin-arm64" "4.2.1"
|
"@tailwindcss/oxide-darwin-arm64" "4.3.1"
|
||||||
"@tailwindcss/oxide-darwin-x64" "4.2.1"
|
"@tailwindcss/oxide-darwin-x64" "4.3.1"
|
||||||
"@tailwindcss/oxide-freebsd-x64" "4.2.1"
|
"@tailwindcss/oxide-freebsd-x64" "4.3.1"
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf" "4.2.1"
|
"@tailwindcss/oxide-linux-arm-gnueabihf" "4.3.1"
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu" "4.2.1"
|
"@tailwindcss/oxide-linux-arm64-gnu" "4.3.1"
|
||||||
"@tailwindcss/oxide-linux-arm64-musl" "4.2.1"
|
"@tailwindcss/oxide-linux-arm64-musl" "4.3.1"
|
||||||
"@tailwindcss/oxide-linux-x64-gnu" "4.2.1"
|
"@tailwindcss/oxide-linux-x64-gnu" "4.3.1"
|
||||||
"@tailwindcss/oxide-linux-x64-musl" "4.2.1"
|
"@tailwindcss/oxide-linux-x64-musl" "4.3.1"
|
||||||
"@tailwindcss/oxide-wasm32-wasi" "4.2.1"
|
"@tailwindcss/oxide-wasm32-wasi" "4.3.1"
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc" "4.2.1"
|
"@tailwindcss/oxide-win32-arm64-msvc" "4.3.1"
|
||||||
"@tailwindcss/oxide-win32-x64-msvc" "4.2.1"
|
"@tailwindcss/oxide-win32-x64-msvc" "4.3.1"
|
||||||
|
|
||||||
"@tybys/wasm-util@^0.10.0", "@tybys/wasm-util@^0.10.1":
|
"@tybys/wasm-util@^0.10.0":
|
||||||
version "0.10.1"
|
version "0.10.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
|
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
|
||||||
integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==
|
integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
|
"@tybys/wasm-util@^0.10.2", "@tybys/wasm-util@^0.10.3":
|
||||||
|
version "0.10.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.4.tgz#3e85ecb266f9d1722250e89bc69528615720b154"
|
||||||
|
integrity sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.4.0"
|
||||||
|
|
||||||
"@types/babel__core@^7.20.5":
|
"@types/babel__core@^7.20.5":
|
||||||
version "7.20.5"
|
version "7.20.5"
|
||||||
resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz"
|
resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz"
|
||||||
@@ -1582,6 +1609,11 @@ deepmerge@^4.3.1:
|
|||||||
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz"
|
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz"
|
||||||
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
|
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
|
||||||
|
|
||||||
|
detect-libc@^1.0.3:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
|
||||||
|
integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==
|
||||||
|
|
||||||
detect-libc@^2.0.3:
|
detect-libc@^2.0.3:
|
||||||
version "2.1.2"
|
version "2.1.2"
|
||||||
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz"
|
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz"
|
||||||
@@ -1622,13 +1654,13 @@ emoji-regex@^9.2.2:
|
|||||||
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
|
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
|
||||||
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
|
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
|
||||||
|
|
||||||
enhanced-resolve@^5.19.0:
|
enhanced-resolve@5.21.6:
|
||||||
version "5.19.0"
|
version "5.21.6"
|
||||||
resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz"
|
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz#aa207b43cf658e6ab3ba06896edc00c13c3127c6"
|
||||||
integrity sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==
|
integrity sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs "^4.2.4"
|
graceful-fs "^4.2.4"
|
||||||
tapable "^2.3.0"
|
tapable "^2.3.3"
|
||||||
|
|
||||||
error-ex@^1.3.1:
|
error-ex@^1.3.1:
|
||||||
version "1.3.4"
|
version "1.3.4"
|
||||||
@@ -2469,10 +2501,10 @@ jest@^30.2.0:
|
|||||||
import-local "^3.2.0"
|
import-local "^3.2.0"
|
||||||
jest-cli "30.2.0"
|
jest-cli "30.2.0"
|
||||||
|
|
||||||
jiti@^2.6.1:
|
jiti@^2.7.0:
|
||||||
version "2.6.1"
|
version "2.7.0"
|
||||||
resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz"
|
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64"
|
||||||
integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==
|
integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==
|
||||||
|
|
||||||
js-tokens@^4.0.0:
|
js-tokens@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
@@ -2549,79 +2581,79 @@ libsodium-wrappers-sumo@^0.8.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
libsodium-sumo "^0.8.0"
|
libsodium-sumo "^0.8.0"
|
||||||
|
|
||||||
lightningcss-android-arm64@1.31.1:
|
lightningcss-android-arm64@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz#609ff48332adff452a8157a7c2842fd692a8eac4"
|
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968"
|
||||||
integrity sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==
|
integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.31.1:
|
lightningcss-darwin-arm64@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz"
|
resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5"
|
||||||
integrity sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==
|
integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
|
||||||
|
|
||||||
lightningcss-darwin-x64@1.31.1:
|
lightningcss-darwin-x64@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz#f7482c311273571ec0c2bd8277c1f5f6e90e03a4"
|
resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e"
|
||||||
integrity sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==
|
integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==
|
||||||
|
|
||||||
lightningcss-freebsd-x64@1.31.1:
|
lightningcss-freebsd-x64@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz#91df1bb290f1cb7bb2af832d7d0d8809225e0124"
|
resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575"
|
||||||
integrity sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==
|
integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==
|
||||||
|
|
||||||
lightningcss-linux-arm-gnueabihf@1.31.1:
|
lightningcss-linux-arm-gnueabihf@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz#c3cad5ae8b70045f21600dc95295ab6166acf57e"
|
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d"
|
||||||
integrity sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==
|
integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==
|
||||||
|
|
||||||
lightningcss-linux-arm64-gnu@1.31.1:
|
lightningcss-linux-arm64-gnu@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz#a5c4f6a5ac77447093f61b209c0bd7fef1f0a3e3"
|
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335"
|
||||||
integrity sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==
|
integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.31.1:
|
lightningcss-linux-arm64-musl@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz#af26ab8f829b727ada0a200938a6c8796ff36900"
|
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133"
|
||||||
integrity sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==
|
integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.31.1:
|
lightningcss-linux-x64-gnu@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz#a891d44e84b71c0d88959feb9a7522bbf61450ee"
|
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6"
|
||||||
integrity sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==
|
integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.31.1:
|
lightningcss-linux-x64-musl@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz#8c8b21def851f4d477fa897b80cb3db2b650bc6e"
|
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b"
|
||||||
integrity sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==
|
integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.31.1:
|
lightningcss-win32-arm64-msvc@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz#79000fb8c57e94a91b8fc643e74d5a54407d7080"
|
resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38"
|
||||||
integrity sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==
|
integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==
|
||||||
|
|
||||||
lightningcss-win32-x64-msvc@1.31.1:
|
lightningcss-win32-x64-msvc@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz#7f025274c81c7d659829731e09c8b6f442209837"
|
resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a"
|
||||||
integrity sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==
|
integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==
|
||||||
|
|
||||||
lightningcss@1.31.1:
|
lightningcss@1.32.0:
|
||||||
version "1.31.1"
|
version "1.32.0"
|
||||||
resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz"
|
resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9"
|
||||||
integrity sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==
|
integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
detect-libc "^2.0.3"
|
detect-libc "^2.0.3"
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
lightningcss-android-arm64 "1.31.1"
|
lightningcss-android-arm64 "1.32.0"
|
||||||
lightningcss-darwin-arm64 "1.31.1"
|
lightningcss-darwin-arm64 "1.32.0"
|
||||||
lightningcss-darwin-x64 "1.31.1"
|
lightningcss-darwin-x64 "1.32.0"
|
||||||
lightningcss-freebsd-x64 "1.31.1"
|
lightningcss-freebsd-x64 "1.32.0"
|
||||||
lightningcss-linux-arm-gnueabihf "1.31.1"
|
lightningcss-linux-arm-gnueabihf "1.32.0"
|
||||||
lightningcss-linux-arm64-gnu "1.31.1"
|
lightningcss-linux-arm64-gnu "1.32.0"
|
||||||
lightningcss-linux-arm64-musl "1.31.1"
|
lightningcss-linux-arm64-musl "1.32.0"
|
||||||
lightningcss-linux-x64-gnu "1.31.1"
|
lightningcss-linux-x64-gnu "1.32.0"
|
||||||
lightningcss-linux-x64-musl "1.31.1"
|
lightningcss-linux-x64-musl "1.32.0"
|
||||||
lightningcss-win32-arm64-msvc "1.31.1"
|
lightningcss-win32-arm64-msvc "1.32.0"
|
||||||
lightningcss-win32-x64-msvc "1.31.1"
|
lightningcss-win32-x64-msvc "1.32.0"
|
||||||
|
|
||||||
lines-and-columns@^1.1.6:
|
lines-and-columns@^1.1.6:
|
||||||
version "1.2.4"
|
version "1.2.4"
|
||||||
@@ -2680,9 +2712,9 @@ merge-stream@^2.0.0:
|
|||||||
resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz"
|
resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz"
|
||||||
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
|
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
|
||||||
|
|
||||||
micromatch@^4.0.8:
|
micromatch@^4.0.5, micromatch@^4.0.8:
|
||||||
version "4.0.8"
|
version "4.0.8"
|
||||||
resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz"
|
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
||||||
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
||||||
dependencies:
|
dependencies:
|
||||||
braces "^3.0.3"
|
braces "^3.0.3"
|
||||||
@@ -2873,7 +2905,7 @@ picomatch@^2.0.4, picomatch@^2.3.1:
|
|||||||
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
|
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
|
||||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||||
|
|
||||||
picomatch@^4.0.2, picomatch@^4.0.3:
|
picomatch@^4.0.2:
|
||||||
version "4.0.3"
|
version "4.0.3"
|
||||||
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz"
|
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz"
|
||||||
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
|
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
|
||||||
@@ -3134,15 +3166,15 @@ synckit@^0.11.8:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@pkgr/core" "^0.2.9"
|
"@pkgr/core" "^0.2.9"
|
||||||
|
|
||||||
tailwindcss@4.2.1, tailwindcss@^4.2.1:
|
tailwindcss@4.3.1:
|
||||||
version "4.2.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz"
|
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.1.tgz#78ee06f6186bc8fb9603f8083eb703dc7dd96a10"
|
||||||
integrity sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==
|
integrity sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==
|
||||||
|
|
||||||
tapable@^2.3.0:
|
tapable@^2.3.3:
|
||||||
version "2.3.0"
|
version "2.3.3"
|
||||||
resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz"
|
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
|
||||||
integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==
|
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
|
||||||
|
|
||||||
test-exclude@^6.0.0:
|
test-exclude@^6.0.0:
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user