Compare commits
23
Commits
f476e77732
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb60b399ec | ||
|
|
447d714313 | ||
|
|
29ba54d5b6 | ||
|
|
e3dd0e44da | ||
|
|
860db6034c | ||
|
|
cd8e45ae0c | ||
|
|
eeb10c20ef | ||
|
|
0aaa94471f | ||
|
|
763b50b0e5 | ||
|
|
4dafd88fad | ||
|
|
1cfd69e72d | ||
|
|
e14f6e9eb5 | ||
|
|
e865099c5b | ||
|
|
cd730241bb | ||
|
|
ad1b4f0557 | ||
|
|
cb23611a17 | ||
|
|
0206b2f77e | ||
|
|
329e64a54d | ||
|
|
88c79e7e05 | ||
|
|
25ead55eac | ||
|
|
a8452a1d46 | ||
|
|
3663f02bf5 | ||
|
|
e590b83df0 |
+77
-7
@@ -1,7 +1,77 @@
|
|||||||
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
|
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||||
# build-info stamping and the Dockerfile runs `make build`, so excluding it
|
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||||
# would make every built extension report commitHash "unknown".
|
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||||
node_modules
|
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||||
.DS_Store
|
# `certs/server.key` still ship while this file reads as solved. Only
|
||||||
dist
|
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||||
release
|
# into .gitignore, where `**/` is wrong.
|
||||||
|
#
|
||||||
|
# Matching is case-sensitive, so secrets use character ranges rather
|
||||||
|
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
||||||
|
#
|
||||||
|
# Extend with this repo's own host-built artifacts, written anchored:
|
||||||
|
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||||
|
# deletes the package directory from the context.
|
||||||
|
|
||||||
|
# .git is sent without its config. Without a VERSION build argument the
|
||||||
|
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||||
|
# does not need .git/config; that file can hold a credential, such as a
|
||||||
|
# password in a remote URL or the token the CI checkout step stores there.
|
||||||
|
# Each submodule keeps a config with the same exposure in its git directory
|
||||||
|
# under .git/modules/, nested again for a submodule's own submodules, or in
|
||||||
|
# its own .git directory when it keeps one.
|
||||||
|
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
||||||
|
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
||||||
|
# `**/.git/modules/**/config` also matches that segment's directory
|
||||||
|
# under .git/modules/. Go's version stamping then fails the build;
|
||||||
|
# nothing leaks. Name such a submodule without that segment:
|
||||||
|
# `git submodule add --name`.
|
||||||
|
**/.git/config
|
||||||
|
**/.git/modules/**/config
|
||||||
|
|
||||||
|
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||||
|
# Anchored because it occurs once where agents run at the repo root.
|
||||||
|
# KNOWN GAP: a repo running agents in subdirectories still ships
|
||||||
|
# `services/api/.claude/` and must add its own anchored entry.
|
||||||
|
.claude
|
||||||
|
|
||||||
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
|
# convention. Re-include a committed template with a negation if the
|
||||||
|
# build needs one: `!docs/example.env`.
|
||||||
|
**/*.[eE][nN][vV]
|
||||||
|
**/.[eE][nN][vV].*
|
||||||
|
**/.[eE][nN][vV][rR][cC]
|
||||||
|
|
||||||
|
# Private keys and the bundles carrying them. Public certificates
|
||||||
|
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
||||||
|
**/*.[pP][eE][mM]
|
||||||
|
**/*.[kK][eE][yY]
|
||||||
|
**/*.[pP]12
|
||||||
|
**/*.[pP][fF][xX]
|
||||||
|
**/[iI][dD]_[rR][sS][aA]
|
||||||
|
**/[iI][dD]_[dD][sS][aA]
|
||||||
|
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
|
**/[iI][dD]_[eE][dD]25519
|
||||||
|
**/[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
|
# Dependencies: restored inside the image, never copied in.
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# OS metadata.
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
|
||||||
|
# Editor state: never a build input, and it churns COPY.
|
||||||
|
**/*.swp
|
||||||
|
**/*.swo
|
||||||
|
**/*~
|
||||||
|
**/*.bak
|
||||||
|
**/.idea
|
||||||
|
**/.vscode
|
||||||
|
**/*.sublime-*
|
||||||
|
|
||||||
|
# This repo's host-built artifacts: make build writes dist/ and make package
|
||||||
|
# writes release/. The image builds its own.
|
||||||
|
/dist
|
||||||
|
/release
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ name: e2e
|
|||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
# The browser end-to-end suites, one job per browser, deliberately kept out
|
# The browser end-to-end suites, one job per browser, deliberately kept out
|
||||||
# of the check workflow: REPO_POLICIES.md caps make test at 20 seconds and
|
# of the check workflow: REPO_POLICIES.md caps make test at 60 seconds and
|
||||||
# script/cibuild is a plain `docker build .` whose Dockerfile runs
|
# script/cibuild runs script/check, so folding a browser suite into either
|
||||||
# make check, so folding a browser suite into either would blow that cap
|
# would blow that cap and slow the local fast path. Before this workflow
|
||||||
# and slow the local fast path. Before this workflow every browser-level
|
# every browser-level guarantee in this repo held only when a human
|
||||||
# guarantee in this repo held only when a human remembered to run it.
|
# remembered to run it.
|
||||||
#
|
#
|
||||||
# One job per browser rather than two steps in one job, so a Chrome failure
|
# One job per browser rather than two steps in one job, so a Chrome failure
|
||||||
# does not hide the Firefox result.
|
# does not hide the Firefox result.
|
||||||
@@ -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
|
||||||
|
|||||||
+31
-5
@@ -11,14 +11,40 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.sublime-*
|
*.sublime-*
|
||||||
|
|
||||||
|
# Agent scratch (worktrees of this repo, created and destroyed by
|
||||||
|
# in-flight tooling). Unanchored: .gitignore patterns already match at
|
||||||
|
# every depth, so no prefix is wanted here. This is not a .dockerignore
|
||||||
|
# entry and must not be given a `**/` prefix on the way into one.
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Environment / secrets
|
# Secrets. Unanchored like every entry above, so each matches at every
|
||||||
.env
|
# depth. Matching is case-sensitive on Linux, so names use character
|
||||||
.env.*
|
# ranges rather than a lowercase form that misses `Server.Key`.
|
||||||
*.pem
|
|
||||||
*.key
|
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||||
|
# convention. Only the templates `example.env` and `sample.env` are
|
||||||
|
# re-included below. A repository that commits any other template adds
|
||||||
|
# its own negation after these lines, for example `!.env.example`.
|
||||||
|
*.[eE][nN][vV]
|
||||||
|
.[eE][nN][vV].*
|
||||||
|
.[eE][nN][vV][rR][cC]
|
||||||
|
!example.env
|
||||||
|
!sample.env
|
||||||
|
|
||||||
|
# Private keys and the bundles carrying them.
|
||||||
|
*.[pP][eE][mM]
|
||||||
|
*.[kK][eE][yY]
|
||||||
|
*.[pP]12
|
||||||
|
*.[pP][fF][xX]
|
||||||
|
[iI][dD]_[rR][sS][aA]
|
||||||
|
[iI][dD]_[dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]
|
||||||
|
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||||
|
[iI][dD]_[eE][dD]25519
|
||||||
|
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
yarn.lock
|
yarn.lock
|
||||||
dist/
|
|
||||||
release/
|
|
||||||
|
|||||||
+67
-28
@@ -1,41 +1,80 @@
|
|||||||
|
# Lint phase: ESLint, prettier --check, and script/check-censored. The tools
|
||||||
|
# are invoked directly rather than through `make lint` or `script/lint`, which
|
||||||
|
# are themselves a docker build and would recurse into a daemon that does not
|
||||||
|
# exist in a build step.
|
||||||
|
#
|
||||||
# node:22-slim (22.x LTS), 2026-02-24
|
# node:22-slim (22.x LTS), 2026-02-24
|
||||||
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base
|
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS lint
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Marks "already inside the lint container" for script/lint, which otherwise
|
|
||||||
# shells out to docker to build the lint stage below. Nothing outside this
|
|
||||||
# image sets it.
|
|
||||||
ENV AUTISTMASK_LINT_NATIVE=1
|
|
||||||
|
|
||||||
# script/test's default 30s bound is the host figure. In here the same suite
|
|
||||||
# starts on a cold jest cache and shares the runner with the rest of the build,
|
|
||||||
# so 30s is too tight — it killed a healthy suite at 30.6s on a cold CI cache.
|
|
||||||
# 180s still catches a hang in three minutes and cannot be tripped by a suite
|
|
||||||
# that is merely running on contended hardware.
|
|
||||||
ENV AUTISTMASK_TEST_TIMEOUT=180
|
|
||||||
|
|
||||||
# script/bootstrap installs all prerequisites (make via apt here; node
|
|
||||||
# is already in the base image, yarn comes via corepack) and runs
|
|
||||||
# yarn install --frozen-lockfile. Dependency manifests are copied first
|
|
||||||
# so the bootstrap layer is cached until they change.
|
|
||||||
COPY script/ script/
|
COPY script/ script/
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN script/bootstrap
|
RUN script/bootstrap
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Lint stage — fail fast on static analysis and formatting, before the tests
|
RUN yarn run lint
|
||||||
# and the build. This is also the stage script/lint builds from a host, which
|
RUN script/check-censored
|
||||||
# is how linting stays on the pinned ESLint rather than the host's.
|
|
||||||
FROM base AS lint
|
# Test phase, same shape and for the same reason: the jest suite (its worker
|
||||||
RUN make lint
|
# cap is in package.json), rerun verbose on failure, then
|
||||||
|
# script/test-verify-build.
|
||||||
|
#
|
||||||
|
# node:22-slim (22.x LTS), 2026-02-24
|
||||||
|
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS test
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY script/ script/
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN script/bootstrap
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN timeout 90 yarn run test || \
|
||||||
|
{ echo "--- Rerunning with --verbose for details ---"; \
|
||||||
|
timeout 90 yarn run test:verbose; exit 1; }
|
||||||
|
RUN script/test-verify-build
|
||||||
|
|
||||||
|
# Development environment with the extension built, and the last stage: a
|
||||||
|
# plain `docker build .` names no target and so builds this one. Nothing is
|
||||||
|
# wanted from the two phases above; the copies are what make BuildKit build
|
||||||
|
# them first, so this image cannot be produced unless lint and test passed. A
|
||||||
|
# stage appended after this one would drop all three out of a plain build.
|
||||||
|
#
|
||||||
|
# node:22-slim (22.x LTS), 2026-02-24
|
||||||
|
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
# Full check and build. The COPY --from is a no-op file copy whose only job is
|
|
||||||
# to make BuildKit finish the lint stage before this one starts; without it the
|
|
||||||
# stages run in parallel and a lint failure would not fail the build early.
|
|
||||||
FROM base AS check
|
|
||||||
COPY --from=lint /app/package.json /dev/null
|
COPY --from=lint /app/package.json /dev/null
|
||||||
|
COPY --from=test /app/package.json /dev/null
|
||||||
|
|
||||||
RUN make check
|
# script/bootstrap installs all prerequisites, git included. Manifests are
|
||||||
RUN make build
|
# copied first so that layer stays cached until dependencies change.
|
||||||
|
COPY script/ script/
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN script/bootstrap
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# The VERSION build arg when one is given, otherwise
|
||||||
|
# `git describe --tags --always` on the .git in the build context. With .git
|
||||||
|
# present, a version that is still empty, dev or unknown fails the build: git
|
||||||
|
# is missing or could not read the checkout, and build.js, which stamps the
|
||||||
|
# extension with the commit it was built from, would stamp "unknown".
|
||||||
|
ARG VERSION
|
||||||
|
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
|
if [ -e .git ]; then \
|
||||||
|
case "$VERSION" in ""|dev|unknown) \
|
||||||
|
echo "version is '$VERSION' although .git is present" >&2; \
|
||||||
|
exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
fi; \
|
||||||
|
make build
|
||||||
|
# A LABEL cannot run git, so it carries the build argument alone; a plain
|
||||||
|
# `docker build .` leaves it empty.
|
||||||
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist clean dev
|
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist icons clean dev
|
||||||
|
|
||||||
# Standard targets are thin shims; the implementations live in script/
|
# Standard targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
@@ -104,9 +104,24 @@ build-debug:
|
|||||||
vendor-blocklist:
|
vendor-blocklist:
|
||||||
@script/vendor-blocklist
|
@script/vendor-blocklist
|
||||||
|
|
||||||
|
# Redraw the toolbar icons in icons/ from script/lib/icons.js, at every size
|
||||||
|
# manifest/chrome.json declares, leaving alone a file that already holds the
|
||||||
|
# drawn image. Commit the result with the drawing: tests/icons.test.js fails
|
||||||
|
# while the two disagree.
|
||||||
|
icons:
|
||||||
|
@node script/lib/icons.js
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -218,35 +218,38 @@ development workflow, and the Makefile targets are thin shims that call them. We
|
|||||||
provide:
|
provide:
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies (pinned node via nvm if needed,
|
- `script/bootstrap` — install all dependencies (pinned node via nvm if needed,
|
||||||
yarn via corepack, `yarn install --frozen-lockfile`)
|
yarn via corepack, `yarn install --frozen-lockfile`), then fail, naming the
|
||||||
|
package, if node cannot find a package listed in `dependencies` or
|
||||||
|
`devDependencies` of `package.json`
|
||||||
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
||||||
git pre-commit hook
|
git pre-commit hook
|
||||||
- `script/projectname` — print the project name (used for the Docker image tag)
|
- `script/projectname` — print the project name (used for the Docker image tag)
|
||||||
- `script/test` — run the test suite (jest)
|
- `script/test` — build the Dockerfile's `test` phase, uncached: the jest suite,
|
||||||
|
stopped after 90 seconds and rerun verbose if it fails, then
|
||||||
|
`script/test-verify-build`
|
||||||
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker is the
|
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker is the
|
||||||
only prerequisite: it builds a pinned image that carries the repo and a fresh
|
only prerequisite: it builds a pinned image that carries the repo and a fresh
|
||||||
extension build, see [End-to-End Tests](#end-to-end-tests))
|
extension build, see [End-to-End Tests](#end-to-end-tests))
|
||||||
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same,
|
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same,
|
||||||
against an image with a pinned Firefox and geckodriver, see
|
against an image with a pinned Firefox and geckodriver, see
|
||||||
[End-to-End Tests](#end-to-end-tests))
|
[End-to-End Tests](#end-to-end-tests))
|
||||||
- `script/lint` — run ESLint (`eslint.config.js`) and then `prettier --check`,
|
- `script/lint` — build the Dockerfile's `lint` phase, uncached: ESLint
|
||||||
failing on either. It never writes: `--fix` is not in this path, so
|
(`eslint.config.js`), `prettier --check`, then `script/check-censored`,
|
||||||
`make check` stays non-mutating. Linting runs in the container — the script
|
failing on any of them. It never writes: `--fix` is not in this path, so
|
||||||
builds the Dockerfile's `lint` stage — because an ESLint result that depends
|
`make check` stays non-mutating. Linting runs only in the container, because
|
||||||
on whichever ESLint the host happens to have is not a result. Docker is
|
an ESLint result that depends on whichever ESLint the host happens to have is
|
||||||
therefore required to lint; inside that image `AUTISTMASK_LINT_NATIVE=1` makes
|
not a result, so docker is required to lint.
|
||||||
the same script lint in place instead of recursing.
|
- `script/fmt` — format all files (writes), on the host
|
||||||
- `script/fmt` — format all files (writes)
|
- `script/fmt-check` — check formatting (read-only), on the host
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/check` — run `script/test`, `script/lint` and `script/fmt-check`
|
||||||
- `script/check` — run test, test-verify-build, check-censored, lint, and
|
|
||||||
fmt-check
|
|
||||||
- `script/check-censored` — assert the competitor name RULES.md bars appears
|
- `script/check-censored` — assert the competitor name RULES.md bars appears
|
||||||
nowhere in the working tree or under `dist/` outside its documented
|
nowhere in the working tree or under `dist/` outside its documented
|
||||||
exceptions: the pinned source reference in `script/vendor-blocklist`, the two
|
exceptions: the pinned source reference in `script/vendor-blocklist`, the two
|
||||||
provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's
|
provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's
|
||||||
on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and
|
on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and
|
||||||
fails anywhere else. Part of `make check`, which inspects `dist/` when there
|
fails anywhere else. Run by the `lint` phase, so part of `make check`; it
|
||||||
is one and says loudly when there is not; `make build` re-runs it with
|
inspects `dist/` when there is one and says loudly when there is not, and the
|
||||||
|
build context of that phase never has one. `make build` re-runs it with
|
||||||
`--require-dist`, so a build artifact is always covered
|
`--require-dist`, so a build artifact is always covered
|
||||||
- `script/package` — produce the release artifacts: `make build` first, so the
|
- `script/package` — produce the release artifacts: `make build` first, so the
|
||||||
archives can only ever be made from a `dist/` that has been verified against
|
archives can only ever be made from a `dist/` that has been verified against
|
||||||
@@ -274,21 +277,30 @@ provide:
|
|||||||
step's exit status, saying on stderr that it did and why. Every step of
|
step's exit status, saying on stderr that it did and why. Every step of
|
||||||
`make build` runs through it; `make build-debug` runs none of them through it.
|
`make build` runs through it; `make build-debug` runs none of them through it.
|
||||||
A step that succeeds removes nothing, and a removal that cannot be completed
|
A step that succeeds removes nothing, and a removal that cannot be completed
|
||||||
is reported as loudly as one that was
|
is reported as loudly as one that was.
|
||||||
- `script/test-verify-build` — exercise every failure mode of
|
- `script/test-verify-build` — exercise every failure mode of
|
||||||
`script/verify-build` against a fixture tree in a temp dir, asserting the exit
|
`script/verify-build` against a fixture tree in a temp dir, asserting the exit
|
||||||
status and the message of each, assert the state of `dist/` on disk after a
|
status and the message of each, assert the state of `dist/` on disk after a
|
||||||
failing and a succeeding release build step, and read the `make build` and
|
failing and a succeeding release build step, and read the `make build` and
|
||||||
`make build-debug` recipes back out of `make -n` to check that they pass the
|
`make build-debug` recipes back out of `make -n` to check that they pass the
|
||||||
mode as an argument on a scrubbed environment and wrap only the release path.
|
mode as an argument on a scrubbed environment and wrap only the release path.
|
||||||
Part of `make check`; it reads no build artifacts and writes nothing under
|
Run by the `test` phase, so part of `make check`; it reads no build artifacts
|
||||||
`dist/`. The cases that depend on file permissions cannot mean anything for a
|
and writes nothing under `dist/`. The cases that depend on file permissions
|
||||||
process that is not subject to them, so the harness proves its runner against
|
cannot mean anything for a process that is not subject to them, so the harness
|
||||||
a mode-000 file before counting them, dropping to an unprivileged user when
|
proves its runner against a mode-000 file before counting them, dropping to an
|
||||||
run as root; if it cannot, it skips those cases and says so in a banner rather
|
unprivileged user when run as root; if it cannot, it skips those cases and
|
||||||
than passing them.
|
says so in a banner rather than passing them.
|
||||||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
- `script/docker` — build the Docker image, uncached and tagged via
|
||||||
- `script/cibuild` — CI entrypoint: plain `docker build .`
|
`script/projectname`: the `lint` and `test` phases, then `make build` in the
|
||||||
|
last stage. It passes what `git describe --tags --always --dirty` prints on
|
||||||
|
the host as the `VERSION` build argument, or `unknown` when that prints
|
||||||
|
nothing, and the last stage fails on `unknown` whenever the build context
|
||||||
|
carries `.git`. Only a build given no `VERSION` at all, such as a plain
|
||||||
|
`docker build .`, runs `git describe` on the `.git` in the build context,
|
||||||
|
which `.dockerignore` sends without its `config`, and fails if git cannot read
|
||||||
|
it
|
||||||
|
- `script/cibuild` — CI entrypoint: `script/bootstrap`, `script/check`, then the
|
||||||
|
same image build as `script/docker`
|
||||||
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook
|
- `script/install-precommit` — install the git pre-commit hook
|
||||||
|
|
||||||
@@ -305,8 +317,21 @@ The Makefile shims to those. It also carries a few targets that have no
|
|||||||
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a
|
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a
|
||||||
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 icons` — redraw the toolbar icons in `icons/` from
|
||||||
|
`script/lib/icons.js`, at every size `manifest/chrome.json` declares. A file
|
||||||
|
that already holds the drawn image, the same IHDR and every pixel, is left
|
||||||
|
untouched. Commit the files with the drawing: `make check` fails while their
|
||||||
|
image differs from what it draws
|
||||||
- `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
|
||||||
|
|
||||||
@@ -314,7 +339,9 @@ There are two suites, one per browser, and they share no code. Chrome runs on
|
|||||||
Playwright; Firefox has its own WebDriver client, because Playwright cannot
|
Playwright; Firefox has its own WebDriver client, because Playwright cannot
|
||||||
observe errors on a Firefox extension page at all — see
|
observe errors on a Firefox extension page at all — see
|
||||||
[Firefox](#firefox-make-test-e2e-firefox) below. Both require docker, and both
|
[Firefox](#firefox-make-test-e2e-firefox) below. Both require docker, and both
|
||||||
are outside `make check`.
|
are outside `make check`. Neither opens the popup from the toolbar button: both
|
||||||
|
load its page in an ordinary tab, so what the toolbar popup itself adds, its
|
||||||
|
size and its closing when it loses focus, is covered by neither.
|
||||||
|
|
||||||
### Chrome (`make test-e2e`)
|
### Chrome (`make test-e2e`)
|
||||||
|
|
||||||
@@ -400,14 +427,36 @@ required to be present, so that check cannot pass by observing nothing. That
|
|||||||
last one is the standing floor under
|
last one is the standing floor under
|
||||||
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
|
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
|
||||||
|
|
||||||
Two limits of that coverage, neither of them papered over. The RPC is stubbed
|
The limits of that coverage and of the rest of the Chrome suite, none of them
|
||||||
throughout, so this is **not** a real dApp against a real network with real
|
papered over:
|
||||||
funds; that remains a human pass before 1.0.0. The site-connection prompt is
|
|
||||||
raised through `chrome.action.openPopup()`, and headless Chromium's
|
- The RPC is stubbed throughout, so this is **not** a real dApp against a real
|
||||||
browser-action popup is not a page Playwright can see or click, so that one
|
network with real funds; that remains a human pass before 1.0.0.
|
||||||
prompt is driven at the URL the extension itself puts on the action — the same
|
- The site-connection prompt is raised through `chrome.action.openPopup()`, and
|
||||||
page and the same approval id, but whether a real toolbar click shows it is not
|
headless Chromium's browser-action popup is not a page Playwright can see or
|
||||||
observable here.
|
click, so that one prompt is driven at the URL the extension itself puts on
|
||||||
|
the action — the same page and the same approval id, but whether a real
|
||||||
|
toolbar click shows it is not observable here.
|
||||||
|
- Each site-connection request is made 1.5 seconds after the tab it will be
|
||||||
|
driven in is opened (`APPROVAL_TAB_SETTLE_MS` in `tests/e2e/run.js`), because
|
||||||
|
opening that tab closes the previous prompt's toolbar popup; a request made
|
||||||
|
just after that popup closes is not covered.
|
||||||
|
- In the tests that approve a signature or a transaction, the approval window
|
||||||
|
runs with `chrome.runtime.sendMessage` wrapped to record what it sends, and in
|
||||||
|
the tests that reject a site connection the prompt gets a click listener that
|
||||||
|
records that Reject was pressed; neither changes what the window does.
|
||||||
|
- The tap to copy test first grants clipboard permission to every page in the
|
||||||
|
browser, which the manifest does not ask for, so whether a real popup may
|
||||||
|
write to the clipboard on a click alone is not covered.
|
||||||
|
- The layout tests for an over-long flash message and for the password error
|
||||||
|
lines write the text straight into the page instead of letting the popup's
|
||||||
|
code put it there, and the second brings each screen up by toggling its
|
||||||
|
`hidden` class rather than navigating to it; they cover the layout, not the
|
||||||
|
code that fills it.
|
||||||
|
- Leaving the recovery phrase or private key screen while its decrypt runs is
|
||||||
|
forced by clicking Reveal and the settings gear in one page task, which a
|
||||||
|
person cannot do; the case a person can hit, the first decrypt after the popup
|
||||||
|
opens while libsodium is still loading, is not driven.
|
||||||
|
|
||||||
Any test that drives a failure path on purpose declares the `console.error` it
|
Any test that drives a failure path on purpose declares the `console.error` it
|
||||||
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
|
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
|
||||||
@@ -421,8 +470,8 @@ collecting for a fixed grace period after the last test returns
|
|||||||
the context. A request whose _first_ dispatch falls after that window is never
|
the context. A request whose _first_ dispatch falls after that window is never
|
||||||
seen at all and cannot fail the run. In practice a request a test fires without
|
seen at all and cannot fail the run. In practice a request a test fires without
|
||||||
awaiting reaches the route handler about 10ms later, and anything on a repeating
|
awaiting reaches the route handler about 10ms later, and anything on a repeating
|
||||||
timer gets observed on an earlier tick during the ~20s suite — but a one-shot
|
timer gets observed on an earlier tick during the suite — but a one-shot call
|
||||||
call deliberately deferred past the window will escape.
|
deliberately deferred past the window will escape.
|
||||||
|
|
||||||
That interception covers the MV3 background service worker as well as the popup
|
That interception covers the MV3 background service worker as well as the popup
|
||||||
page, which it does not by default — `script/test-e2e` sets
|
page, which it does not by default — `script/test-e2e` sets
|
||||||
@@ -554,25 +603,18 @@ without an `await`. Demonstrated, not assumed: a `throw` placed past the first
|
|||||||
and on Chrome (`pageerror`), with the rest of the run unaffected because the
|
and on Chrome (`pageerror`), with the rest of the run unaffected because the
|
||||||
approval view had already rendered.
|
approval view had already rendered.
|
||||||
|
|
||||||
One error is tolerated rather than fatal, listed in `ALLOWED_ERRORS` in
|
Errors are read from the privileged `nsIConsoleService` in Marionette's chrome
|
||||||
`tests/e2e/firefox/run.js` with the issue that will delete it, and printed on
|
context and filtered to non-warning entries whose `sourceName` is the extension
|
||||||
every occurrence so the concession stays visible in the run output. It is
|
origin. That mechanism is not a stylistic choice. WebDriver BiDi's
|
||||||
Firefox reporting the site-approval popup's unawaited `sendMessage` settling
|
`log.entryAdded` delivers **nothing** for extension pages: on a plain `http://`
|
||||||
after `window.close()` unloaded the context — the same teardown ordering as
|
page it reports uncaught errors with stack traces, and on the `moz-extension://`
|
||||||
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275), and unsuppressable from
|
popup it reports zero events, because Firefox's remote agent excludes extension
|
||||||
the calling code, because `BaseContext.wrapPromise` reports it whether or not a
|
browsing contexts from BiDi observation. Any harness built on Playwright-BiDi or
|
||||||
handler is attached. Errors are read from the privileged `nsIConsoleService` in
|
Puppeteer-BiDi would therefore see nothing and report success, which is exactly
|
||||||
Marionette's chrome context and filtered to non-warning entries whose
|
the vacuous check this repo has already shipped twice. Do not migrate this suite
|
||||||
`sourceName` is the extension origin. That mechanism is not a stylistic choice.
|
to BiDi.
|
||||||
WebDriver BiDi's `log.entryAdded` delivers **nothing** for extension pages: on a
|
|
||||||
plain `http://` page it reports uncaught errors with stack traces, and on the
|
|
||||||
`moz-extension://` popup it reports zero events, because Firefox's remote agent
|
|
||||||
excludes extension browsing contexts from BiDi observation. Any harness built on
|
|
||||||
Playwright-BiDi or Puppeteer-BiDi would therefore see nothing and report
|
|
||||||
success, which is exactly the vacuous check this repo has already shipped twice.
|
|
||||||
Do not migrate this suite to BiDi.
|
|
||||||
|
|
||||||
Two limits are worth knowing, both real differences from the Chrome suite:
|
Three limits are worth knowing, all real differences from the Chrome suite:
|
||||||
|
|
||||||
- **Error capture is poll-based, not event-streamed.** The console is drained at
|
- **Error capture is poll-based, not event-streamed.** The console is drained at
|
||||||
each step boundary, so an error is attributed to the step it was drained
|
each step boundary, so an error is attributed to the step it was drained
|
||||||
@@ -589,27 +631,33 @@ Two limits are worth knowing, both real differences from the Chrome suite:
|
|||||||
and silently evicts the oldest, so more than 250 console messages between two
|
and silently evicts the oldest, so more than 250 console messages between two
|
||||||
drains destroys the excess unread. 400 throws inside one step are reported as
|
drains destroys the excess unread. 400 throws inside one step are reported as
|
||||||
exactly the newest 250, three runs running. That buffer is shared with
|
exactly the newest 250, three runs running. That buffer is shared with
|
||||||
Firefox's own console noise; a clean run peaks at 4 of 250 at the install
|
Firefox's own console noise; a clean run, measured when the suite had three
|
||||||
drain and 0 at every later drain, so the three steps here have wide headroom,
|
steps (popup load, wallet creation and Add Token), peaked at 4 of 250 at the
|
||||||
but a step that logs heavily could evict unread errors. What poll-based costs
|
install drain and 0 at every later drain, but a step that logs heavily could
|
||||||
is location, not coverage: an error cannot be placed within a step the way the
|
evict unread errors. What poll-based costs is location, not coverage: an error
|
||||||
Chrome suite's `pageerror` events place it.
|
cannot be placed within a step the way the Chrome suite's `pageerror` events
|
||||||
|
place it.
|
||||||
- **Almost nothing is stubbed, which inverts the coverage of network-dependent
|
- **Almost nothing is stubbed, which inverts the coverage of network-dependent
|
||||||
code.** The container still runs with `--network none`, so the run is offline
|
code.** The container still runs with `--network none`, so the run is offline
|
||||||
and no request can escape. The one thing it can reach is the loopback fixture
|
and no request can escape. The one thing it can reach is the loopback fixture
|
||||||
in `tests/e2e/firefox/dapp.js`, which serves the dApp page and a JSON-RPC node
|
in `tests/e2e/firefox/dapp.js`, which serves the dApp page and a JSON-RPC node
|
||||||
and which the extension's `rpcUrl` is pointed at for the dApp steps; a
|
and which the extension's `rpcUrl` is pointed at for the dApp steps; a
|
||||||
JSON-RPC method that fixture does not model fails the run rather than
|
JSON-RPC method that fixture does not model fails the run rather than
|
||||||
answering `null`. Everything else — Blockscout, the price feed, the phishing
|
answering `null`. Everything else, Blockscout and the price feed among it, has
|
||||||
blocklist — has no fixture and simply fails, and the extension swallows its
|
no fixture and simply fails, and the extension swallows its own fetch
|
||||||
own fetch failures, so only the _failure_ branches of that code are ever
|
failures, so only the _failure_ branches of that code are ever executed. A
|
||||||
executed. A `ReferenceError` in the success path of `renderTransactions`, or
|
`ReferenceError` in the success path of `renderTransactions`, or of price
|
||||||
of price rendering, passes this suite green. The offline run is also weaker
|
rendering, passes this suite green. The offline run is also weaker than the
|
||||||
than the Chrome suite's interception for those calls: it proves nothing got
|
Chrome suite's interception for those calls: it proves nothing got out, but it
|
||||||
out, but it cannot report which requests were attempted.
|
cannot report which requests were attempted.
|
||||||
|
- **The site-connection prompt always opens in a window of its own.** The
|
||||||
|
profile turns off `extensions.openPopupWithoutUserGesture.enabled`, so
|
||||||
|
`src/background/index.js` falls back from the toolbar popup, which WebDriver
|
||||||
|
cannot see, to `windows.create()`; the toolbar popup path is not covered on
|
||||||
|
Firefox.
|
||||||
|
|
||||||
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
|
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
|
||||||
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
|
`make test`. `REPO_POLICIES.md` caps `make test` at 60 seconds and a browser
|
||||||
suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot
|
suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot
|
||||||
pick it up either. Run them locally before changing anything under
|
pick it up either. Run them locally before changing anything under
|
||||||
`src/popup/views/`.
|
`src/popup/views/`.
|
||||||
@@ -618,7 +666,7 @@ pick it up either. Run them locally before changing anything under
|
|||||||
|
|
||||||
`.gitea/workflows/e2e.yml` runs both suites on every push, as two jobs —
|
`.gitea/workflows/e2e.yml` runs both suites on every push, as two jobs —
|
||||||
`e2e-chrome` and `e2e-firefox` — separate from the `check` workflow, so the
|
`e2e-chrome` and `e2e-firefox` — separate from the `check` workflow, so the
|
||||||
20-second `make test` cap and the local fast path are untouched. Each job is a
|
60-second `make test` cap and the local fast path are untouched. Each job is a
|
||||||
checkout and the matching `script/` entrypoint, nothing else.
|
checkout and the matching `script/` entrypoint, nothing else.
|
||||||
|
|
||||||
Docker is the only thing either job needs from the runner, and that is not an
|
Docker is the only thing either job needs from the runner, and that is not an
|
||||||
@@ -643,12 +691,22 @@ 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: `e2e-chrome` 1m44s to 4m48s, and `e2e-firefox`
|
||||||
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
|
31s to 4m07s. A cold cache adds three to four minutes to each job, spent
|
||||||
Firefox and geckodriver downloads the same way. The `e2e-chrome` figures predate
|
rebuilding its image: reinstalling dependencies and, for `e2e-firefox`,
|
||||||
the two cases that wait for a receipt to end in error, which add about two
|
installing Firefox, geckodriver and their system libraries. Both scripts now
|
||||||
minutes of real waiting.
|
build their image with `--no-cache`, so every run pays the cold figure. Those
|
||||||
|
`e2e-chrome` runs predate the cases that wait in real time for a receipt to end
|
||||||
|
in error. `make test-e2e` took 3m51s locally with its image cached, so with the
|
||||||
|
image rebuilt every run, an `e2e-chrome` run comes to about seven minutes.
|
||||||
|
|
||||||
|
Each e2e job has a `timeout-minutes` cap, so a hung build or browser ends the
|
||||||
|
job instead of holding the shared runner: `e2e-firefox` 15 minutes 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. The `check` job has no cap: `.gitea/workflows/check.yml` is the canonical
|
||||||
|
copy from `sneak/prompts`, kept byte-identical.
|
||||||
|
|
||||||
### Element id guard (part of `make check`)
|
### Element id guard (part of `make check`)
|
||||||
|
|
||||||
@@ -1205,14 +1263,21 @@ path rather than on the home screen. Read the claim narrowly, as that file
|
|||||||
states it: what those boots prove is no structural dereference on the code paths
|
states it: what those boots prove is no structural dereference on the code paths
|
||||||
a WHOLLY-CORRUPTED PROFILE takes, which is not every path a stored record takes.
|
a WHOLLY-CORRUPTED PROFILE takes, which is not every path a stored record takes.
|
||||||
Not driven: any pairing of values the four slots do not produce, a view only
|
Not driven: any pairing of values the four slots do not produce, a view only
|
||||||
forward navigation opens, anything behind a click, and everything a healthy
|
forward navigation opens, anything behind a click or a timer, and, of what a
|
||||||
profile reaches. Within that boundary the verdict is unconditional — if one of
|
healthy profile reaches, anything beyond its boot onto each view the popup can
|
||||||
those boots leaves the popup unhealthy or off the view it stored, `make check`
|
reopen onto. The fields the router does not read share a slot on each boot, so
|
||||||
fails, including when it takes two corrupted fields at once, because the verdict
|
one of them truthy while another is falsy is reached only where the falsy slot
|
||||||
is the combined boot and the per-field re-boot that names a culprit can only
|
pairs a field that cannot be falsy with one that is. Within that boundary the
|
||||||
decorate the message. So does a field that gains a floor while its row still
|
verdict is unconditional — if one of those boots leaves the popup unhealthy,
|
||||||
claims it has none, and so does a field added to `PERSISTED_FIELDS` with no row
|
`make check` fails, including when it takes two corrupted fields at once,
|
||||||
at all. The per-field justification that used to live in the header of
|
because the verdict is the combined boot and the per-field re-boot that names a
|
||||||
|
culprit can only decorate the message. The combined boot must also land on the
|
||||||
|
view it stored, and each value driven only onto the restore path must land on
|
||||||
|
its view, or fall back to Home, as its row declares; a field the router reads
|
||||||
|
can legitimately change which view renders, so its own sweep is held to health
|
||||||
|
alone. `make check` also fails on a field that gains a floor while its row still
|
||||||
|
claims it has none, and on a field added to `PERSISTED_FIELDS` with no row at
|
||||||
|
all. The per-field justification that used to live in the header of
|
||||||
`src/shared/stateSchema.js` shipped a false claim in three consecutive changes,
|
`src/shared/stateSchema.js` shipped a false claim in three consecutive changes,
|
||||||
each caught only by a reviewer re-deriving thirty fields by hand.
|
each caught only by a reviewer re-deriving thirty fields by hand.
|
||||||
|
|
||||||
@@ -1253,11 +1318,14 @@ behind a "···" menu.
|
|||||||
|
|
||||||
Navigation uses a stack model (like iOS): each forward action pushes the current
|
Navigation uses a stack model (like iOS): each forward action pushes the current
|
||||||
screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and
|
screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and
|
||||||
`goBack()` in `src/popup/views/helpers.js`). The root screen is either Welcome
|
`goBack()` in `src/popup/views/helpers.js`). "Back" skips an entry for the
|
||||||
(no wallets) or Home (has wallets). Each screen below gives its view id in
|
screen already showing: ShowRecoveryPhrase and the two delete screens take
|
||||||
parentheses; the registry of view ids is the `VIEWS` array in
|
themselves off the stack when left, so the Settings gear on one of them leaves
|
||||||
`src/popup/views/helpers.js`, and the markup for a screen is the element with id
|
Settings under Settings, and "Back" from there goes to the screen before
|
||||||
`view-` plus that view id in `src/popup/index.html`.
|
Settings. The root screen is either Welcome (no wallets) or Home (has wallets).
|
||||||
|
Each screen below gives its view id in parentheses; the registry of view ids is
|
||||||
|
the `VIEWS` array in `src/popup/views/helpers.js`, and the markup for a screen
|
||||||
|
is the element with id `view-` plus that view id in `src/popup/index.html`.
|
||||||
|
|
||||||
Three elements sit outside the screens and are present on all of them: the title
|
Three elements sit outside the screens and are present on all of them: the title
|
||||||
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
|
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
|
||||||
@@ -1383,14 +1451,17 @@ view would leave a wallet one click from deletion.
|
|||||||
without it, the lost-password route on DeleteWallet is the first they
|
without it, the lost-password route on DeleteWallet is the first they
|
||||||
would hear of it. The hint line reserves its height, so switching tabs
|
would hear of it. The hint line reserves its height, so switching tabs
|
||||||
cannot move the password fields under the pointer.
|
cannot move the password fields under the pointer.
|
||||||
- "Import" button
|
- "Import" button, with the error line beside it so that it adds no height
|
||||||
|
to a screen whose button already starts near the bottom of the popup
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Import" with a valid entry and a matching password of at least 12
|
- "Import" with a valid entry and a matching password of at least 12
|
||||||
characters → creates the wallet, clears the navigation stack, and →
|
characters → creates the wallet, clears the navigation stack, and →
|
||||||
**Home**. The phrase and xprv modes then scan for further used addresses
|
**Home**. The phrase and xprv modes then scan for further used addresses
|
||||||
and report the count as a flash message.
|
and report the count as a flash message.
|
||||||
- "Import" with an invalid entry, a duplicate wallet or address, or a short
|
- "Import" with a missing, short or mismatched password → full-sentence
|
||||||
or mismatched password → flash message, no screen change
|
error on the error line, no screen change
|
||||||
|
- "Import" with an invalid entry or a duplicate wallet or address → flash
|
||||||
|
message, no screen change
|
||||||
- "Back" → previous screen (Welcome, Home, or Settings)
|
- "Back" → previous screen (Welcome, Home, or Settings)
|
||||||
|
|
||||||
#### AddressDetail (`address`)
|
#### AddressDetail (`address`)
|
||||||
@@ -1429,8 +1500,8 @@ view would leave a wallet one click from deletion.
|
|||||||
copy)
|
copy)
|
||||||
- Warning that anyone holding the private key can transfer all funds from
|
- Warning that anyone holding the private key can transfer all funds from
|
||||||
the address
|
the address
|
||||||
- Error line
|
- Password input, error line and "Reveal" button, shown until the key is
|
||||||
- Password input and "Reveal" button, shown until the key is revealed
|
revealed
|
||||||
- The private key on a highlighted background, tap to copy, shown only after
|
- The private key on a highlighted background, tap to copy, shown only after
|
||||||
the password has been accepted
|
the password has been accepted
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
@@ -1440,12 +1511,23 @@ view would leave a wallet one click from deletion.
|
|||||||
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
|
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
|
||||||
revealed (no screen change)
|
revealed (no screen change)
|
||||||
- "Back" → previous screen (AddressDetail)
|
- "Back" → previous screen (AddressDetail)
|
||||||
|
- Settings gear → **Settings**, whose "Back" goes to AddressDetail: leaving
|
||||||
|
drops the address the screen was showing, so it also takes the screen off
|
||||||
|
the Back stack
|
||||||
- **Secret handling**: nothing is decrypted, no key is derived, and nothing is
|
- **Secret handling**: nothing is decrypted, no key is derived, and nothing is
|
||||||
written into the page until the password is accepted; the key is never stored
|
written into the page until the password is accepted; the key is never stored
|
||||||
in state, and it is wiped from the page whenever the screen is left by any
|
in state, and it is wiped from the page whenever the screen is left by any
|
||||||
route, including the Settings gear. A decrypt still running when the screen is
|
route, including the Settings gear. A decrypt still running when the screen is
|
||||||
left is discarded rather than written. The screen is not restorable, so
|
left is discarded rather than written. The screen is not restorable, so
|
||||||
reopening the popup lands on Home rather than back on the key.
|
reopening the popup lands on Home rather than back on the key.
|
||||||
|
- **Clipboard**: tapping the key copies it to the clipboard, and the wallet
|
||||||
|
never clears the clipboard afterwards; leaving the screen wipes the key from
|
||||||
|
the page only. The clipboard is the user's, not the wallet's. Clearing it
|
||||||
|
would go against what the user expects, and by then they may have copied
|
||||||
|
something else vital that the clear would destroy. The user knows the key is
|
||||||
|
secret from the warning above the password input, which says that anyone with
|
||||||
|
it can access and transfer all funds from the address, and knows it is on the
|
||||||
|
clipboard because they copied it. From then on it is theirs to manage.
|
||||||
|
|
||||||
#### AddressToken (`address-token`)
|
#### AddressToken (`address-token`)
|
||||||
|
|
||||||
@@ -1757,8 +1839,8 @@ view would leave a wallet one click from deletion.
|
|||||||
- Wallet name
|
- Wallet name
|
||||||
- Warning box stating that anyone holding these words can take everything in
|
- Warning box stating that anyone holding these words can take everything in
|
||||||
the wallet, from any device, without the password
|
the wallet, from any device, without the password
|
||||||
- Error line
|
- Password input, error line and "Reveal" button, shown until the password
|
||||||
- Password input + "Reveal" button, shown until the password is accepted
|
is accepted
|
||||||
- The recovery phrase itself, in full and click-to-copy, shown only after a
|
- The recovery phrase itself, in full and click-to-copy, shown only after a
|
||||||
correct password and in place of the password prompt
|
correct password and in place of the password prompt
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
@@ -1767,12 +1849,18 @@ view would leave a wallet one click from deletion.
|
|||||||
- "Reveal" (wrong password) → full-sentence error, nothing revealed (no
|
- "Reveal" (wrong password) → full-sentence error, nothing revealed (no
|
||||||
screen change)
|
screen change)
|
||||||
- "Back" → previous screen (Settings)
|
- "Back" → previous screen (Settings)
|
||||||
|
- Settings gear → **Settings**, whose "Back" never lands back on this
|
||||||
|
screen: leaving drops the wallet the screen was showing, so it also takes
|
||||||
|
the screen off the Back stack
|
||||||
- **Secret handling**: nothing is decrypted or written into the page until the
|
- **Secret handling**: nothing is decrypted or written into the page until the
|
||||||
password is accepted; the phrase is never stored in state, and it is wiped
|
password is accepted; the phrase is never stored in state, and it is wiped
|
||||||
from the page whenever the screen is left by any route, including the Settings
|
from the page whenever the screen is left by any route, including the Settings
|
||||||
gear. A decrypt still running when the screen is left is discarded rather than
|
gear. A decrypt still running when the screen is left is discarded rather than
|
||||||
written. The screen is not restorable, so reopening the popup lands on Home
|
written. The screen is not restorable, so reopening the popup lands on Home
|
||||||
rather than back on the phrase.
|
rather than back on the phrase.
|
||||||
|
- **Clipboard**: tapping the phrase copies it, and the wallet never clears the
|
||||||
|
clipboard afterwards, for the reasons given under ExportPrivKey; here the
|
||||||
|
warning box above the password input is what tells the user it is secret.
|
||||||
|
|
||||||
#### DeleteWallet (`delete-wallet-confirm`)
|
#### DeleteWallet (`delete-wallet-confirm`)
|
||||||
|
|
||||||
@@ -1781,8 +1869,8 @@ view would leave a wallet one click from deletion.
|
|||||||
- "Back" button, "Delete Wallet" heading
|
- "Back" button, "Delete Wallet" heading
|
||||||
- Warning naming the wallet and stating that deletion is permanent and any
|
- Warning naming the wallet and stating that deletion is permanent and any
|
||||||
funds are unrecoverable without the recovery phrase
|
funds are unrecoverable without the recovery phrase
|
||||||
- Error line
|
|
||||||
- Password input
|
- Password input
|
||||||
|
- Error line
|
||||||
- "Confirm Delete" button
|
- "Confirm Delete" button
|
||||||
- An underlined "I have lost my password" control
|
- An underlined "I have lost my password" control
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
@@ -1802,6 +1890,9 @@ view would leave a wallet one click from deletion.
|
|||||||
try again." on the error line, nothing deleted
|
try again." on the error line, nothing deleted
|
||||||
- "I have lost my password" → **DeleteWalletLostPassword**
|
- "I have lost my password" → **DeleteWalletLostPassword**
|
||||||
- "Back" → previous screen (Settings)
|
- "Back" → previous screen (Settings)
|
||||||
|
- Settings gear → **Settings**, whose "Back" never lands back on this
|
||||||
|
screen: leaving drops the wallet the screen was showing, so it also takes
|
||||||
|
the screen off the Back stack
|
||||||
|
|
||||||
#### DeleteWalletLostPassword (`delete-wallet-lost-password`)
|
#### DeleteWalletLostPassword (`delete-wallet-lost-password`)
|
||||||
|
|
||||||
@@ -1840,6 +1931,9 @@ view would leave a wallet one click from deletion.
|
|||||||
selection comes back with it. The two delete screens are siblings rather
|
selection comes back with it. The two delete screens are siblings rather
|
||||||
than parent and child: nothing is pushed on the way here, so both have
|
than parent and child: nothing is pushed on the way here, so both have
|
||||||
Settings as their Back target.
|
Settings as their Back target.
|
||||||
|
- Settings gear → **Settings**, whose "Back" never lands back on this
|
||||||
|
screen: leaving drops the wallet the screen was showing, so it also takes
|
||||||
|
the screen off the Back stack
|
||||||
- **Deliberately not password-gated.** A password in front of _discarding_ a
|
- **Deliberately not password-gated.** A password in front of _discarding_ a
|
||||||
secret protects nobody: an attacker at the popup who wants the wallet gone can
|
secret protects nobody: an attacker at the popup who wants the wallet gone can
|
||||||
uninstall the extension, so the only person such a gate stops is the owner who
|
uninstall the extension, so the only person such a gate stops is the owner who
|
||||||
@@ -2190,8 +2284,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 |
|
||||||
|
|
||||||
|
|||||||
+355
-84
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-07-06
|
last_modified: 2026-10-04
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -60,17 +60,28 @@ style conventions are in separate documents:
|
|||||||
prerequisite since nvm requires bash. yarn is then pinned via
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||||
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||||
scripts are our own extensions to the standard: `script/check` runs
|
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||||
what the git pre-commit hook runs, and it calls `script/check`;
|
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
pristine checkout with nothing installed the run dies there, after the
|
||||||
target shims to it); and `script/projectname` (literally that filename) simply
|
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||||
outputs the project's name. Scripts that need the name call
|
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||||
so those scripts stay byte-identical across all repos. Repo-type-specific
|
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
source nvm for the pinned node version before invoking it, exactly as
|
||||||
`script/precommit`, not in the hook itself. Model scripts are at
|
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
||||||
|
docker and git then gets through `script/check`. Four further scripts are our
|
||||||
|
own extensions to the standard: `script/check` runs `script/test`,
|
||||||
|
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
|
||||||
|
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
|
||||||
|
installs the git pre-commit hook (the `make hooks` target shims to it); and
|
||||||
|
`script/projectname` (literally that filename) simply outputs the project's
|
||||||
|
name. Scripts that need the name call `script/projectname` — e.g.
|
||||||
|
`script/docker` assembles its image tag from it — so those scripts stay
|
||||||
|
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
|
||||||
|
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
|
||||||
|
the hook itself. Model scripts are at
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
must document the provided scripts in an **Entrypoints** section (see the
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
README requirements below).
|
README requirements below).
|
||||||
@@ -89,87 +100,198 @@ style conventions are in separate documents:
|
|||||||
contributor should be able to understand the entire development workflow by
|
contributor should be able to understand the entire development workflow by
|
||||||
reading the Makefile.
|
reading the Makefile.
|
||||||
|
|
||||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
||||||
as a build step so the build fails if the branch is not green. For non-server
|
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||||
repos, the Dockerfile should bring up a development environment and run
|
image cannot be built unless they pass. For non-server repos the final stage
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
brings up a development environment; for server repos it is the runtime image.
|
||||||
stage before the final image is assembled. Dockerfiles install development
|
The gate phases and the build stage start from their pinned base images and
|
||||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
repo's own `Dockerfile` does for its yarn packages. The development
|
||||||
layer stays cached until dependencies change.
|
environment stage installs development prerequisites by running
|
||||||
|
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
||||||
|
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
||||||
|
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
||||||
|
|
||||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||||
repos use a multistage build where linting runs in an independent stage based
|
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||||
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
and nothing else:
|
||||||
`make fmt-check` and `make lint` before the full build begins. The build stage
|
|
||||||
then declares an explicit dependency on the lint stage via
|
|
||||||
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
|
||||||
linting before proceeding to compilation and tests. This ensures lint failures
|
|
||||||
surface in seconds rather than minutes, without blocking on dependency
|
|
||||||
download or compilation in the build stage.
|
|
||||||
|
|
||||||
The standard pattern for a Go repo Dockerfile is:
|
```sh
|
||||||
|
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
|
||||||
|
docker build --no-cache --target test -t "$(script/projectname)-test" .
|
||||||
|
```
|
||||||
|
|
||||||
|
**A stage that is not the last one in the file is built only when the final
|
||||||
|
stage's chain depends on it, or when `--target` names it.** That is why the
|
||||||
|
two gates are always invoked by name here, and why the final stage carries a
|
||||||
|
`COPY --from=` of a harmless file from each of them: without that edge a
|
||||||
|
plain `docker build .` builds the last stage alone and exits 0 having linted
|
||||||
|
and tested nothing.
|
||||||
|
|
||||||
|
**Every `docker build` in `script/` is tagged**, here and in
|
||||||
|
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
||||||
|
image behind on every invocation, on every developer host and every CI
|
||||||
|
runner; a tagged one replaces the previous image.
|
||||||
|
|
||||||
|
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
||||||
|
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
||||||
|
themselves a `docker build` and would recurse into a daemon that does not
|
||||||
|
exist in a build step. Formatting is the exception and stays on the host:
|
||||||
|
`script/fmt` writes the working tree, and `script/fmt-check` is its
|
||||||
|
read-only twin.
|
||||||
|
|
||||||
|
**No lint verdict may come from a host invocation of the linter.** On a
|
||||||
|
shared host golangci-lint reads a result cache keyed on file content rather
|
||||||
|
than location, so a second checkout of the same content is served the first
|
||||||
|
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||||
|
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||||
|
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||||
|
org, in both directions. A container has its own cache, its own `TMPDIR` and
|
||||||
|
a digest-pinned binary, so neither is reachable.
|
||||||
|
|
||||||
|
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
|
||||||
|
a `COPY` layer only when the copied content changes, so on an unchanged tree
|
||||||
|
the check `RUN` is served from cache, nothing executes, and the build still
|
||||||
|
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
|
||||||
|
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
|
||||||
|
four, and there is no fifth — `script/check` runs the two gate phases and
|
||||||
|
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
|
||||||
|
not evidence that anything ran: a sub-second build reporting success is a
|
||||||
|
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
||||||
|
and friends destroy a build cache shared with every other build on the host.
|
||||||
|
When a check is added or changed, prove it works by planting a defect it must
|
||||||
|
catch and watching the run fail on it, then revert the defect. A green run
|
||||||
|
alone shows neither that the check ran nor that it covers what it should.
|
||||||
|
|
||||||
|
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||||
|
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||||
|
hash), so lint failures surface in seconds rather than after a full compile,
|
||||||
|
and the test phase is based on the Debian Go image. The canonical Go repo
|
||||||
|
`Dockerfile`:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint phase
|
||||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
FROM golangci/golangci-lint@sha256:... AS lint
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make fmt-check
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
RUN make lint
|
|
||||||
|
|
||||||
# Build stage
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# image ships and the alpine one does not.
|
||||||
FROM golang@sha256:... AS builder
|
# golang:1.x, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS test
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Force BuildKit to run the lint stage before proceeding
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make test
|
RUN go test -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
ARG VERSION=dev
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
RUN CGO_ENABLED=0 go build -trimpath \
|
# are what make BuildKit build them first, so this stage cannot run
|
||||||
|
# unless lint and test passed.
|
||||||
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS builder
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# The VERSION build arg when one is given, otherwise
|
||||||
|
# `git describe --tags --always` on the .git in the build context. With
|
||||||
|
# .git present, a version that is still empty, dev or unknown fails the
|
||||||
|
# build: git is missing or could not read the checkout.
|
||||||
|
ARG VERSION
|
||||||
|
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
|
if [ -e .git ]; then \
|
||||||
|
case "$VERSION" in ""|dev|unknown) \
|
||||||
|
echo "version is '$VERSION' although .git is present" >&2; \
|
||||||
|
exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
fi; \
|
||||||
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
-o /app ./cmd/app/
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage, and the last one
|
||||||
FROM alpine@sha256:...
|
FROM alpine@sha256:...
|
||||||
COPY --from=builder /app /usr/local/bin/app
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
ENTRYPOINT ["app"]
|
ENTRYPOINT ["app"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||||
includes both Go and the linter), so there is no need to install the
|
both Go and the linter), so nothing needs installing.
|
||||||
linter separately.
|
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
and a stage nothing depends on is not built at all, so without these two
|
||||||
this line, the build stage would not wait for lint to finish and a lint
|
lines a red gate would not fail the build.
|
||||||
failure might not fail the overall build.
|
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||||
|
same two copies. A plain `docker build .` builds the last stage's chain
|
||||||
|
and nothing else.
|
||||||
- If the project uses `//go:embed` directives that reference build artifacts
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||||
create placeholder files so the embed directives resolve. Example:
|
create placeholder files so the embed directives resolve. Example:
|
||||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
The lint stage should not depend on the actual build output — it exists to
|
- If the project requires CGO or system libraries for linting, install them
|
||||||
fail fast.
|
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||||
- If the project requires CGO or system libraries for linting (e.g.
|
has no `apk`, so install with `apt-get` under the Debian package name
|
||||||
`vips-dev`), install them in the lint stage with `apk add`.
|
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
lists in the same `RUN`, so the layer does not keep them:
|
||||||
build stage, not the lint stage, because they may require compiled
|
|
||||||
artifacts or heavier dependencies.
|
```dockerfile
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||||
|
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
||||||
|
repository's own, each submodule's under `.git/modules/`, and that of a
|
||||||
|
submodule keeping its own `.git` directory. `git describe` does not need
|
||||||
|
them, and each can hold a credential: a password in a remote URL, or the
|
||||||
|
token the CI checkout step stores there. A submodule whose name has a
|
||||||
|
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
||||||
|
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
||||||
|
then fails the build: give it a name without that segment
|
||||||
|
(`git submodule add --name`). The stage that compiles has `git` (the
|
||||||
|
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
||||||
|
takes the version from the `VERSION` build argument when one is given,
|
||||||
|
otherwise from `git describe --tags --always`. That gives the tag on a
|
||||||
|
tagged commit; on a later commit, the tag, the number of commits since it
|
||||||
|
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
||||||
|
tag is reachable. The stage that compiles also marks its working directory
|
||||||
|
safe for git (`git config --system --add safe.directory /src`): a context
|
||||||
|
sent as a tar stream keeps the sender's file owners, and git refuses a
|
||||||
|
checkout owned by another user, so the version would come out empty.
|
||||||
|
`ARG VERSION` has no default, and the build fails if the context carries
|
||||||
|
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
||||||
|
`docker build .` with no build arguments must succeed; a Dockerfile that
|
||||||
|
refuses an empty build argument drops that refusal and keeps the argument.
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||||
Dockerfile already runs `make check`, a successful build implies all checks
|
That script bootstraps, runs the gate phases, and then builds the image, so a
|
||||||
pass.
|
successful run means every check passed; a bare `docker build .` does not
|
||||||
|
carry the same guarantee, because its gate phases may come from the cache. The
|
||||||
|
image build is uncached and so runs the gate phases a second time. That is the
|
||||||
|
price of the rule above, and it is worth paying: the image that ships is built
|
||||||
|
from a run of its own gates rather than from a cache entry. A separate
|
||||||
|
workflow limited to `main` by a `branches` list under `on: push` cannot be
|
||||||
|
checked by review: to try a change to it, add the feature branch to that list
|
||||||
|
and push, then remove the branch from the list again before merging. Keep any
|
||||||
|
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
|
||||||
|
from the feature branch publishes nothing.
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
@@ -189,14 +311,21 @@ style conventions are in separate documents:
|
|||||||
module under test to verify it compiles/parses. There is no excuse for
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
`make test` to be a no-op.
|
`make test` to be a no-op.
|
||||||
|
|
||||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
||||||
Makefile.
|
suite that exceeds it fails. Under 20 seconds is the target. A suite between
|
||||||
|
20 and 60 seconds is still green, but the overage must be filed as an
|
||||||
|
improvement bug against that repo. Add a 90-second timeout to the test
|
||||||
|
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
|
||||||
|
hard cap so that it catches a genuinely hung test rather than a merely slow
|
||||||
|
one.
|
||||||
|
|
||||||
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||||
show full output. This keeps CI logs and `docker build` output clean on
|
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||||
success (just package/suite summaries) while providing full diagnostic detail
|
on success (just package/suite summaries) while providing full diagnostic
|
||||||
on failure (every test case, every assertion). The general shell pattern:
|
detail on failure (every test case, every assertion). The command lives in the
|
||||||
|
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
|
||||||
|
Makefile form below is the same pattern for any repo-local invocation:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@@ -209,11 +338,26 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@go test -timeout 30s -race -cover ./... || \
|
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -timeout 30s -race -v ./...; exit 1; }
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`-count=1` is required on both invocations: it defeats Go's test _result_
|
||||||
|
cache, so neither run can report a stored pass in place of running the
|
||||||
|
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
||||||
|
and no recompilation.
|
||||||
|
|
||||||
|
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||||
|
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||||
|
run again on unchanged code it prints that result, marked `(cached)`,
|
||||||
|
without running them. That matters on a developer's machine, where this
|
||||||
|
target runs and the directory lasts from one run to the next. The `test`
|
||||||
|
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
|
||||||
|
result for this repo's tests and nothing before its `go test` step runs a
|
||||||
|
test, so there is nothing to replay. `--no-cache` (above) is what makes that
|
||||||
|
step run on an unchanged tree.
|
||||||
|
|
||||||
Python example:
|
Python example:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
@@ -239,10 +383,84 @@ style conventions are in separate documents:
|
|||||||
must be in `.gitignore`. No exceptions.
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||||
Fetch the standard `.gitignore` from
|
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||||
a new repo.
|
setting up a new repo. These patterns are written to `.gitignore`'s own
|
||||||
|
semantics, in which an unanchored pattern already matches at every depth; they
|
||||||
|
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
||||||
|
|
||||||
|
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
|
||||||
|
across unmodified leaves secrets in the build context.** Docker matches with
|
||||||
|
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
|
||||||
|
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
|
||||||
|
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
|
||||||
|
therefore excludes only the copies at the repository root, while `config/.env`
|
||||||
|
and `certs/server.key` still reach the context and can land in an image layer
|
||||||
|
— which is more dangerous than a short file with no secret patterns at all,
|
||||||
|
because it reads as solved and stops anyone looking. Give every
|
||||||
|
depth-independent pattern the `**/` prefix and leave only genuinely
|
||||||
|
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
|
||||||
|
binary, written `/myapp` and never `**/myapp`, which would also match
|
||||||
|
`cmd/myapp/` and delete the package directory from the context. Matching is
|
||||||
|
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
||||||
|
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
|
||||||
|
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
|
||||||
|
also catches something the build needs, re-include it with a negation
|
||||||
|
(`!docs/example.env`); deleting the pattern reopens the exposure for every
|
||||||
|
other file it covers. Fetch the standard `.dockerignore` from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
|
||||||
|
it with the repo's own artifacts.
|
||||||
|
|
||||||
|
- **In-repo agent scratch belongs in both files, written to each file's own
|
||||||
|
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
|
||||||
|
additional checkout of the repo — so under `COPY . .` the build context
|
||||||
|
inflates by a multiple of the repo and another session's unreviewed work can
|
||||||
|
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
|
||||||
|
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
|
||||||
|
prefix, because the prefixed form would also delete any nested directory of
|
||||||
|
that name from the build. Anchoring carries a known gap that the canonical
|
||||||
|
`.dockerignore` states in its own comment, since consuming repos receive the
|
||||||
|
file and not the tracker: the directory is created in the agent's working
|
||||||
|
directory, so a repo running agents in subdirectories still ships
|
||||||
|
`services/api/.claude/` and must add its own anchored entry there.
|
||||||
|
|
||||||
|
- **A plain `docker build .` of a clone stamps the version that
|
||||||
|
`git describe --tags --always` gives**, derived from the `.git` in the build
|
||||||
|
context as the canonical `Dockerfile` above shows. Without its failure check,
|
||||||
|
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
||||||
|
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
||||||
|
the version they compute on the host; it takes precedence. They do this
|
||||||
|
byte-identically across repos:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Own line: a failing command substitution inside an argument does not
|
||||||
|
# trip `set -e`, so the inline form degrades to an empty constant.
|
||||||
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
|
[ -n "$version" ] || version="unknown"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$(script/projectname)" .
|
||||||
|
```
|
||||||
|
|
||||||
|
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||||
|
than failing, and the `[ -n "$version" ]` line is the single place the
|
||||||
|
fallback is applied — a live check that fires on a build from an export with
|
||||||
|
no `.git` and on a repository with no commits yet. Do not fold it into the
|
||||||
|
substitution as `|| echo unknown`, which makes the guard unreachable. The
|
||||||
|
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
|
||||||
|
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
||||||
|
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
||||||
|
the scripts stay byte-identical. One consequence for CI: the standard
|
||||||
|
checkout action clones shallow and fetches no tags, so a repo that embeds a
|
||||||
|
tag-derived version must set `fetch-depth: 0` on its checkout step.
|
||||||
|
|
||||||
|
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
||||||
|
patterns.** Plant files at the root _and_ at least two directories deep, build
|
||||||
|
a probe image that does `COPY . .`, and list what actually landed
|
||||||
|
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
|
||||||
|
size is not a substitute: a nested secret is a few bytes, and BuildKit
|
||||||
|
transfers only the delta from the previous build.
|
||||||
|
|
||||||
- **No build artifacts in version control.** Code-derived data (compiled
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
@@ -258,9 +476,56 @@ style conventions are in separate documents:
|
|||||||
- Make all changes on a feature branch. You can do whatever you want on a
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
feature branch.
|
feature branch.
|
||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
||||||
manually by the user. Fetch from
|
_NEVER_ be modified by an agent: fetch it from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
||||||
|
byte-identical, so that no repo can quietly loosen its own linting. Linter
|
||||||
|
configuration changes are made to the canonical copy in the `prompts` repo and
|
||||||
|
reach consuming repos by re-vendoring; an agent may open a PR against
|
||||||
|
canonical, which only the user merges. One list is exempt from byte-identity,
|
||||||
|
because it cannot be written once for every repo: the `deny` list of the
|
||||||
|
`test-support` depguard rule, where a repo names its own test-support packages
|
||||||
|
by full import path. A repo adds entries there and changes nothing else, and a
|
||||||
|
re-vendor carries its entries forward. The canonical golangci-lint version is
|
||||||
|
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
||||||
|
image
|
||||||
|
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
||||||
|
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
||||||
|
directive must not name a newer Go minor version than the one golangci-lint
|
||||||
|
was built with, or golangci-lint refuses to lint it: this release lints
|
||||||
|
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
|
||||||
|
installs golangci-lint on the host. A repo sets the lint phase digest to the
|
||||||
|
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
|
||||||
|
the two prompted the change: the canonical copy can name linters that an older
|
||||||
|
golangci-lint rejects, and a newer golangci-lint can add linters that
|
||||||
|
`default: all` switches on until the canonical copy disables them.
|
||||||
|
|
||||||
|
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||||
|
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
||||||
|
`PATH` only, so on an already-provisioned machine the pin is inert and a
|
||||||
|
version bump is a silent no-op — while the Dockerfile, installing into a clean
|
||||||
|
image, gets the pinned version, so a local `make check` and `make docker` can
|
||||||
|
disagree about what the tool even is. The canonical form:
|
||||||
|
- compares the installed version against the pin over the **whole** version
|
||||||
|
token; a parser that stops at the first `-` reports `2.12.2` for a host
|
||||||
|
running `2.12.2-rc1` and skips the install;
|
||||||
|
- treats absent, non-zero, empty or unrecognised `--version` output as a
|
||||||
|
mismatch, so the failure direction is a redundant install and never a
|
||||||
|
skipped one;
|
||||||
|
- after installing, re-resolves the binary the way callers do — `hash -r`,
|
||||||
|
then through `PATH`, not through the directory the installer wrote to —
|
||||||
|
and fails naming the resolved path, since an install that a shadowing
|
||||||
|
binary hides succeeds while changing nothing any caller sees;
|
||||||
|
- is actually called, and prints the version on both success paths: a
|
||||||
|
function defined and never invoked has the same exit status and the same
|
||||||
|
empty output as one that worked.
|
||||||
|
|
||||||
|
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||||
|
|
||||||
|
A Go tool a repo needs on the host is installed with `go install` pinned to
|
||||||
|
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
|
||||||
|
a `go.mod` tool dependency or through a `tools.go` file, either of which
|
||||||
|
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
|
||||||
|
|
||||||
- When pinning images or packages by hash, add a comment above the reference
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
with the version and date (YYYY-MM-DD).
|
with the version and date (YYYY-MM-DD).
|
||||||
@@ -374,12 +639,14 @@ style conventions are in separate documents:
|
|||||||
settings.
|
settings.
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
and language-specific config). Everything else goes in a subdirectory.
|
||||||
subdirectory names:
|
Canonical subdirectory names:
|
||||||
- `bin/` — executable scripts and tools
|
- `bin/` — executable scripts and tools
|
||||||
- `cmd/` — Go command entrypoints
|
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||||
|
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||||
|
`cmd/`
|
||||||
- `configs/` — configuration templates and examples
|
- `configs/` — configuration templates and examples
|
||||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
- `docs/` — documentation and markdown (README.md stays in root)
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
@@ -406,3 +673,7 @@ style conventions are in separate documents:
|
|||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
- Python: `pyproject.toml`
|
- Python: `pyproject.toml`
|
||||||
|
|
||||||
|
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
|
||||||
|
is never committed under a file or directory named after one agent tool, such
|
||||||
|
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
|
||||||
|
|||||||
@@ -23,28 +23,277 @@
|
|||||||
|
|
||||||
pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The
|
pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The
|
||||||
milestone is in flight on `next`; its `next` -> `main` PR is
|
milestone is in flight on `next`; its `next` -> `main` PR is
|
||||||
[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified
|
[#388](https://git.eeqj.de/sneak/AutistMask/pulls/388). `make build` produces
|
||||||
green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces
|
`dist/chrome/` and `dist/firefox/` with `DEBUG` compiled off, and checks them
|
||||||
`dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to
|
against the build's own receipt to hold exactly the regular files and symlinks
|
||||||
hold exactly the regular files and symlinks that build emitted, with `DEBUG`
|
that build emitted.
|
||||||
compiled off.
|
|
||||||
|
|
||||||
The backlog lives on the
|
The backlog lives on the
|
||||||
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
|
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
|
||||||
authoritative; this file does not duplicate it. Full policy file set present.
|
authoritative; this file does not duplicate it. Full policy file set present.
|
||||||
Real-browser end-to-end suites (`make test-e2e` for Chrome,
|
Real-browser end-to-end suites (`make test-e2e` for Chrome,
|
||||||
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does
|
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which runs the
|
||||||
static analysis as well as formatting, and `.gitea/workflows/e2e.yml` runs both
|
tests, static analysis and the formatting check, and `.gitea/workflows/e2e.yml`
|
||||||
of them on every push.
|
runs both of them on every push.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
|
Cut 1.0.0 once the
|
||||||
input validation) before any 1.0rc tag. Individual filed issues are parts of it,
|
[1.0.0 milestone](https://git.eeqj.de/sneak/AutistMask/milestone/6) is empty,
|
||||||
but the review is broader than any of them.
|
then continue tagging as milestones land.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-07: Two holes in what `tests/persistedFieldContract.test.js` checks
|
||||||
|
are closed ([#379](https://git.eeqj.de/sneak/AutistMask/issues/379)). The
|
||||||
|
check that every swept field is driven both truthy and falsy counts only
|
||||||
|
`hostile` and `falsy` values, which the sweep drives onto every restorable
|
||||||
|
view, and no longer a `hostileRestore` value, which reaches only the views its
|
||||||
|
entry names; the stale index 5 moves into `hostile` for `selectedWallet` and
|
||||||
|
`selectedAddress`, as the one value of either still truthy after the floor.
|
||||||
|
Each `hostileRestore` entry declares whether its boot lands on its view or
|
||||||
|
falls back to Home, and is held to it. The limits that remain, fields that
|
||||||
|
share a slot on one boot and anything no stored record reaches by itself, are
|
||||||
|
restated as built in the file's header and the README, which now also say
|
||||||
|
which boots are held to where the popup lands and that a healthy profile is
|
||||||
|
booted onto every restorable view.
|
||||||
|
|
||||||
|
- 2026-10-07: Every password error in the popup is shown the same way
|
||||||
|
([#493](https://git.eeqj.de/sneak/AutistMask/issues/493)): with `showError()`
|
||||||
|
and `hideError()` in a fixed-height error line below the password field, as
|
||||||
|
the send confirmation and approval screens already did. The add wallet screen
|
||||||
|
showed a missing, short or mismatched password in the flash line, and the
|
||||||
|
private key export, recovery phrase and delete wallet screens each had a line
|
||||||
|
of their own above the field. On the add wallet screen the line sits beside
|
||||||
|
the Import button, so the button stays where it was at 360x600. Each line
|
||||||
|
clears when the screen is shown again and when the password is tried again.
|
||||||
|
The add wallet screen's other messages, such as an invalid recovery phrase, a
|
||||||
|
duplicate wallet and the address scan, stay in the flash line.
|
||||||
|
`tests/passwordErrorLines.test.js` drives all four screens in the popup.
|
||||||
|
|
||||||
|
- 2026-10-07: Pre-1.0 security review of the extension
|
||||||
|
([#383](https://git.eeqj.de/sneak/AutistMask/issues/383)), reading the tree at
|
||||||
|
`99292b9` for key handling, the DEBUG mode policy, and what the background
|
||||||
|
accepts from pages, the configured RPC endpoint and the explorer, with what
|
||||||
|
the approval screens show from it; the site permission model and storage were
|
||||||
|
read as well. Its summary on that issue lists ten findings, each filed as its
|
||||||
|
own issue, and all ten are fixed on `next`; one,
|
||||||
|
[#399](https://git.eeqj.de/sneak/AutistMask/issues/399), put funds at risk.
|
||||||
|
Three decisions it raised are still open with the owner: the Argon2id cost for
|
||||||
|
the vault key ([#401](https://git.eeqj.de/sneak/AutistMask/issues/401)), a
|
||||||
|
connected site switching the network with no prompt
|
||||||
|
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)), and `eth_sign`
|
||||||
|
signing as a personal message
|
||||||
|
([#409](https://git.eeqj.de/sneak/AutistMask/issues/409)). Not covered: the
|
||||||
|
end-to-end suites were not run, the bundled phishing blocklist and token list
|
||||||
|
were not checked entry by entry, `ethers` and `libsodium-wrappers-sumo` were
|
||||||
|
taken as audited, and nothing was tried against a real network with real funds
|
||||||
|
([#385](https://git.eeqj.de/sneak/AutistMask/issues/385)). The planned
|
||||||
|
independent second check of each finding did not run; the findings rest on the
|
||||||
|
reviewer's own reading of the code.
|
||||||
|
|
||||||
|
- 2026-10-07: Stale branches pruned from `origin`
|
||||||
|
([#167](https://git.eeqj.de/sneak/AutistMask/issues/167)). The issue
|
||||||
|
classifies each branch it lists, with the evidence. The eighteen still on
|
||||||
|
`origin` that it classifies as landed, or superseded by merged pull requests,
|
||||||
|
were deleted. `feat/message-signing` and `fix/59-transaction-view-ui-policies`
|
||||||
|
had been deleted on 2026-09-09; both landed and stay deleted. Four are kept
|
||||||
|
because their work is not in `next`: `fix/consistent-error-display`,
|
||||||
|
`fix/87-consistent-error-display` and `fix/87-consistent-error-display-v2`,
|
||||||
|
the change for [#87](https://git.eeqj.de/sneak/AutistMask/issues/87) that
|
||||||
|
never landed, as reference for
|
||||||
|
[#493](https://git.eeqj.de/sneak/AutistMask/issues/493); and
|
||||||
|
`chore/token-list-enrichment`, deleted on 2026-09-09 and re-created at
|
||||||
|
`f7a2437`, whose `scripts/` tooling waits on
|
||||||
|
[#495](https://git.eeqj.de/sneak/AutistMask/issues/495).
|
||||||
|
|
||||||
|
- 2026-10-07: The README says that the wallet never clears the clipboard after
|
||||||
|
the private key or the recovery phrase is copied, and why
|
||||||
|
([#492](https://git.eeqj.de/sneak/AutistMask/issues/492)): the clipboard is
|
||||||
|
the user's, clearing it would go against what they expect, and it could
|
||||||
|
destroy something else they copied since. It is under ExportPrivKey, with a
|
||||||
|
line under ShowRecoveryPhrase, and names the warning each password screen
|
||||||
|
actually shows, which says nothing about the clipboard.
|
||||||
|
|
||||||
|
- 2026-10-07: The Firefox end-to-end suite no longer tolerates any uncaught
|
||||||
|
extension error ([#487](https://git.eeqj.de/sneak/AutistMask/issues/487)). Its
|
||||||
|
one entry, Firefox reporting a popup promise that settled after the page
|
||||||
|
unloaded, had lost its cause with
|
||||||
|
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275); the entry and the
|
||||||
|
code that printed tolerated errors are gone from `tests/e2e/firefox/run.js`,
|
||||||
|
and so is the README paragraph that described it.
|
||||||
|
|
||||||
|
- 2026-10-07: The toolbar icons in `icons/` can be redrawn in the tree
|
||||||
|
([#378](https://git.eeqj.de/sneak/AutistMask/issues/378)): `make icons` runs
|
||||||
|
`script/lib/icons.js`, which draws the mark and writes each PNG with node's
|
||||||
|
own `zlib`, no new dependency, leaving alone a file that already holds the
|
||||||
|
drawn image. `tests/icons.test.js` requires each committed file to hold
|
||||||
|
exactly that image, the same IHDR and every pixel. The compressed bytes are
|
||||||
|
not compared, because node's bundled zlib does not compress them as the stock
|
||||||
|
zlib that made the committed files did. `build.js` now copies each manifest
|
||||||
|
from the same path `copyIcons()` reads its icons from.
|
||||||
|
|
||||||
|
- 2026-10-07: The README's end-to-end limits now match what the two browser
|
||||||
|
suites do to the extension
|
||||||
|
([#293](https://git.eeqj.de/sneak/AutistMask/issues/293)). The `window.close`
|
||||||
|
override the issue named went with
|
||||||
|
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275), so it needs no entry.
|
||||||
|
Added: both suites load the popup in a tab rather than from the toolbar;
|
||||||
|
Chrome waits 1.5 seconds before each site-connection request, records what
|
||||||
|
approval windows send and click, grants clipboard permission, writes text
|
||||||
|
straight into the page in two layout tests, and forces the leave during a
|
||||||
|
decrypt; Firefox forces the site-connection prompt into a window. Corrected:
|
||||||
|
Firefox's one tolerated error, whose cause that fix removed (the entry goes in
|
||||||
|
[#487](https://git.eeqj.de/sneak/AutistMask/issues/487)), the phishing
|
||||||
|
blocklist the extension no longer fetches, and two stale figures.
|
||||||
|
|
||||||
|
- 2026-10-07: Back from Settings no longer shows Settings again after the
|
||||||
|
settings gear was pressed on the recovery phrase or a delete wallet screen
|
||||||
|
opened from Settings, with or without a reopen in between
|
||||||
|
([#481](https://git.eeqj.de/sneak/AutistMask/issues/481)). Those screens take
|
||||||
|
themselves off the Back stack when left, which leaves Settings under Settings;
|
||||||
|
`goBack()` now skips an entry for the screen already showing.
|
||||||
|
`tests/showPhrase.test.js`, `tests/deleteWalletLostPassword.test.js` and
|
||||||
|
`tests/backNavigation.test.js` drive each path.
|
||||||
|
|
||||||
|
- 2026-10-07: `script/discard-dist-on-failure` returns the failed step's own
|
||||||
|
exit status even when it cannot write its message, to a closed stderr or to a
|
||||||
|
pipe nobody reads any more
|
||||||
|
([#342](https://git.eeqj.de/sneak/AutistMask/issues/342)); it used to return 2
|
||||||
|
or 141 instead. An interrupt while a step runs now removes nothing and says
|
||||||
|
nothing whichever shell `/bin/sh` is, even when the step catches it and exits
|
||||||
|
with a status of its own, as `script/check-censored` does: the wrapper exits
|
||||||
|
with 130 once the step has ended. Under bash such a step used to have `dist/`
|
||||||
|
removed. A failed `check-censored --require-dist` still removes `dist/` like
|
||||||
|
any other step, because a `dist/` not cleared of the name `RULES.md` bars must
|
||||||
|
not ship. The header states both. `script/test-verify-build` runs the wrapper
|
||||||
|
with stderr closed and with stderr a pipe nobody reads, and interrupts it
|
||||||
|
under dash and under bash.
|
||||||
|
|
||||||
|
- 2026-10-06: Back from Settings no longer lands on the delete wallet or
|
||||||
|
lost-password screen after either was left by the settings gear
|
||||||
|
([#480](https://git.eeqj.de/sneak/AutistMask/issues/480)), the defect
|
||||||
|
[#461](https://git.eeqj.de/sneak/AutistMask/issues/461) fixed for the two
|
||||||
|
secret screens. Leaving drops the screen's wallet selection, so its leave
|
||||||
|
handler now also takes it off the Back stack, as a reopened popup already
|
||||||
|
does. `tests/deleteWalletLostPassword.test.js` drives the gear and then Back
|
||||||
|
on both screens.
|
||||||
|
|
||||||
|
- 2026-10-06: `script/bootstrap` no longer reports success while node cannot
|
||||||
|
find a package listed in `dependencies` or `devDependencies` of `package.json`
|
||||||
|
([#263](https://git.eeqj.de/sneak/AutistMask/issues/263)). After the install
|
||||||
|
it asks node for each one's `package.json`, and fails naming the missing
|
||||||
|
package and the fix. yarn skips the install whenever
|
||||||
|
`node_modules/.yarn-integrity` matches `yarn.lock`, so a package deleted from
|
||||||
|
`node_modules` stayed deleted while bootstrap said it was complete. The
|
||||||
|
fresh-clone failure the issue reports did not reproduce.
|
||||||
|
|
||||||
|
- 2026-10-06: Back from Settings no longer lands on the private key export or
|
||||||
|
recovery phrase screen after either was left by the settings gear
|
||||||
|
([#461](https://git.eeqj.de/sneak/AutistMask/issues/461)). Leaving drops the
|
||||||
|
screen's selection, so its leave handler now also takes it off the Back stack,
|
||||||
|
as a reopened popup already does. `tests/exportPrivkey.test.js` and
|
||||||
|
`tests/showPhrase.test.js` drive the gear and then Back, and
|
||||||
|
`leavePrivkeyScreen()` in `tests/e2e/run.js` expects the address screen after
|
||||||
|
Settings.
|
||||||
|
|
||||||
|
- 2026-10-06: A token symbol or name read off a contract is no longer stored cut
|
||||||
|
between the two halves of an emoji
|
||||||
|
([#458](https://git.eeqj.de/sneak/AutistMask/issues/458)). `lookupTokenInfo()`
|
||||||
|
cut both by UTF-16 units; it now counts code points, as `displaySymbol()` has
|
||||||
|
since [#329](https://git.eeqj.de/sneak/AutistMask/issues/329).
|
||||||
|
`tests/tokenLookupTruncation.test.js` looks up a token whose symbol and name
|
||||||
|
are made of emoji outside the Basic Multilingual Plane. A symbol already
|
||||||
|
stored broken is not repaired.
|
||||||
|
|
||||||
|
- 2026-10-06: Reloading or closing the popup no longer logs a request it cancels
|
||||||
|
as a failure in the transaction lists and ENS name lookups on the address and
|
||||||
|
token screens, the address scan after a wallet is created, the endpoint checks
|
||||||
|
in Settings, the wait screen's receipt check, the Send screen's Max fee
|
||||||
|
estimate, or the token lookup on the two add-token screens
|
||||||
|
([#475](https://git.eeqj.de/sneak/AutistMask/issues/475)). Each checks the
|
||||||
|
signal the popup aborts on `pagehide`
|
||||||
|
([#218](https://git.eeqj.de/sneak/AutistMask/issues/218)) before reporting a
|
||||||
|
failure; a real failure is still logged. `scanForAddresses()`,
|
||||||
|
`resolveEnsNames()` and `lookupTokenInfo()` take the signal. The e2e suite
|
||||||
|
reloads the popup on the address screen with Blockscout held, and during the
|
||||||
|
address scan with that scan held; jest tests cover each of these with the
|
||||||
|
popup open and closed. Left out: the transaction detail screen and the
|
||||||
|
confirmation screen (its fee estimate and its recipient checks), because they
|
||||||
|
discard the popup context that carries the signal.
|
||||||
|
|
||||||
|
- 2026-10-06: The private key export screen opens again in the same popup
|
||||||
|
session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)). `show()`
|
||||||
|
found the address line through the element inside it, which its own rendering
|
||||||
|
replaced, so the second open threw before it navigated. The line now carries
|
||||||
|
the `export-privkey-address` id itself. `tests/exportPrivkey.test.js` opens
|
||||||
|
the screen twice, its DOM stub now takes an element out of the document when
|
||||||
|
its parent's contents are replaced, and the `#253` e2e case no longer reopens
|
||||||
|
the popup before its second open.
|
||||||
|
|
||||||
|
- 2026-10-06: The canonical files are re-vendored from `sneak/prompts` at
|
||||||
|
`dd4027b` ([#472](https://git.eeqj.de/sneak/AutistMask/issues/472)). The
|
||||||
|
`Dockerfile` has separate `lint` and `test` phases, and its last stage depends
|
||||||
|
on both before it runs `make build`. `script/lint` and `script/test` each
|
||||||
|
build one phase, uncached. `script/check-censored` runs in the `lint` phase
|
||||||
|
and `script/test-verify-build` in the `test` phase. `script/check` runs the
|
||||||
|
two phases and `script/fmt-check`. `script/cibuild` bootstraps, runs
|
||||||
|
`script/check`, then builds the image uncached. Given no `VERSION` build
|
||||||
|
argument, as in a plain `docker build .`, the image build takes one from
|
||||||
|
`git describe` on the `.git` in the build context, which `.dockerignore` now
|
||||||
|
sends without its `config`. The vendored `check.yml` has no `timeout-minutes`,
|
||||||
|
so the `check` job's cap is gone.
|
||||||
|
|
||||||
|
- 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
|
- 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
|
([#328](https://git.eeqj.de/sneak/AutistMask/issues/328)): `style-src` is
|
||||||
`'self'` in both manifests, pinned in `tests/manifest.test.js`. The 42
|
`'self'` in both manifests, pinned in `tests/manifest.test.js`. The 42
|
||||||
@@ -1685,6 +1934,3 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
Only work that has no issue of its own belongs here; everything else is on the
|
Only work that has no issue of its own belongs here; everything else is on the
|
||||||
tracker.
|
tracker.
|
||||||
|
|
||||||
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
|
|
||||||
land.
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -31,7 +40,7 @@ const AUDITED_MODULE = "src/shared/constants.js";
|
|||||||
// the build, whether or not a text matcher would have recognized it. A
|
// the build, whether or not a text matcher would have recognized it. A
|
||||||
// background entry point the table does not name fails as well, so a second
|
// background entry point the table does not name fails as well, so a second
|
||||||
// worker is protected by default rather than by someone remembering this file.
|
// worker is protected by default rather than by someone remembering this file.
|
||||||
// Dockerfile:42 runs `make build`, so it is enforced in CI.
|
// The Dockerfile's last stage runs `make build`, so it is enforced in CI.
|
||||||
|
|
||||||
// The build receipt: every file this build emits, with its sha256 and whether
|
// The build receipt: every file this build emits, with its sha256 and whether
|
||||||
// it is one of the audited bundles. script/verify-build is handed this and
|
// it is one of the audited bundles. script/verify-build is handed this and
|
||||||
@@ -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(
|
||||||
@@ -575,15 +588,10 @@ async function build() {
|
|||||||
copyIcons(distDir);
|
copyIcons(distDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy manifests
|
// copy manifests, the same files copyIcons() read
|
||||||
copyEmitted(
|
for (const [distDir, manifestPath] of MANIFEST_SOURCES) {
|
||||||
path.join(__dirname, "manifest", "chrome.json"),
|
copyEmitted(manifestPath, path.join(distDir, "manifest.json"));
|
||||||
path.join(DIST_CHROME, "manifest.json"),
|
}
|
||||||
);
|
|
||||||
copyEmitted(
|
|
||||||
path.join(__dirname, "manifest", "firefox.json"),
|
|
||||||
path.join(DIST_FIREFOX, "manifest.json"),
|
|
||||||
);
|
|
||||||
|
|
||||||
assertForbiddenTableCovered(forbiddenRecord);
|
assertForbiddenTableCovered(forbiddenRecord);
|
||||||
|
|
||||||
@@ -597,10 +605,74 @@ 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}`,
|
||||||
@@ -608,16 +680,19 @@ if (require.main === module) {
|
|||||||
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,
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-3
@@ -16,9 +16,9 @@ so the "release commit" throughout is the `main` commit the milestone PR merged.
|
|||||||
## Procedure
|
## Procedure
|
||||||
|
|
||||||
1. **Confirm `main` is green in CI.** The `check` workflow
|
1. **Confirm `main` is green in CI.** The `check` workflow
|
||||||
(`.gitea/workflows/check.yml`) runs `script/cibuild`, i.e. `docker build .`,
|
(`.gitea/workflows/check.yml`) runs `script/cibuild`, which runs
|
||||||
and the `Dockerfile` runs `make check` as a build step, so a green `check`
|
`script/check` and then builds the image uncached, so a green `check` run is
|
||||||
run is a green `make check`. Find the run for the exact release commit on the
|
a green `make check`. Find the run for the exact release commit on the
|
||||||
tracker's Actions view. _Check:_ that commit's `check` run succeeded; running
|
tracker's Actions view. _Check:_ that commit's `check` run succeeded; running
|
||||||
`make check` on a clean checkout of the commit reproduces it and exits 0.
|
`make check` on a clean checkout of the commit reproduces it and exits 0.
|
||||||
|
|
||||||
|
|||||||
+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",
|
||||||
|
|||||||
@@ -127,6 +127,40 @@ install_js_deps() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# run_node: run node from the repo root, through nvm when node is not on PATH;
|
||||||
|
# the script comes on stdin
|
||||||
|
run_node() {
|
||||||
|
if missing node && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||||
|
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && node"
|
||||||
|
else
|
||||||
|
node
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# yarn install exits 0 without touching node_modules once
|
||||||
|
# node_modules/.yarn-integrity matches yarn.lock, so a package deleted from
|
||||||
|
# node_modules stays deleted. Fail unless node finds every package listed in
|
||||||
|
# dependencies and devDependencies of package.json, by its package.json. When a
|
||||||
|
# package's exports does not list that file, node throws
|
||||||
|
# ERR_PACKAGE_PATH_NOT_EXPORTED, which it can only do once it has found the
|
||||||
|
# package, so that error counts as found.
|
||||||
|
check_js_deps() {
|
||||||
|
run_node <<'EOF'
|
||||||
|
const { dependencies, devDependencies } = require("./package.json");
|
||||||
|
for (const name of Object.keys({ ...dependencies, ...devDependencies })) {
|
||||||
|
try {
|
||||||
|
require.resolve(name + "/package.json");
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") {
|
||||||
|
console.error(`bootstrap: node cannot find ${name} after yarn install`);
|
||||||
|
console.error(" fix: rm -rf node_modules && make bootstrap");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@@ -136,6 +170,7 @@ main() {
|
|||||||
ensure_node
|
ensure_node
|
||||||
ensure_yarn
|
ensure_yarn
|
||||||
install_js_deps
|
install_js_deps
|
||||||
|
check_js_deps
|
||||||
|
|
||||||
echo "bootstrap complete"
|
echo "bootstrap complete"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,14 +1,14 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/check: run all checks (test, test-verify-build, lint, fmt-check).
|
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||||
# Our own extension to scripts-to-rule-them-all. Must not modify any files.
|
# extension to scripts-to-rule-them-all. test and lint are Docker
|
||||||
|
# phases; fmt-check is native, because a formatter writes the working
|
||||||
|
# tree. Must not modify any files.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
"$SCRIPT_DIR/test"
|
"$SCRIPT_DIR/test"
|
||||||
"$SCRIPT_DIR/test-verify-build"
|
|
||||||
"$SCRIPT_DIR/check-censored"
|
|
||||||
"$SCRIPT_DIR/lint"
|
"$SCRIPT_DIR/lint"
|
||||||
"$SCRIPT_DIR/fmt-check"
|
"$SCRIPT_DIR/fmt-check"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/check-censored: assert that the competitor name RULES.md bars appears
|
# script/check-censored: assert that the competitor name RULES.md bars appears
|
||||||
# nowhere in this repo, and nowhere in the built extension, except where it is
|
# nowhere in this repo, and nowhere in the built extension, except where it is
|
||||||
# deliberate. Our own extension to scripts-to-rule-them-all, run from
|
# deliberate. Our own extension to scripts-to-rule-them-all, run by the
|
||||||
# script/check and from make build.
|
# Dockerfile's lint phase and by make build.
|
||||||
#
|
#
|
||||||
# Where the name is allowed, and why each one is not negotiable away:
|
# Where the name is allowed, and why each one is not negotiable away:
|
||||||
#
|
#
|
||||||
|
|||||||
+19
-4
@@ -1,13 +1,28 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. The Dockerfile runs make check, so
|
# script/cibuild: run the CI build. It bootstraps first: a CI runner
|
||||||
# a successful build implies all checks pass.
|
# checks out and runs this and nothing else, and script/fmt-check runs
|
||||||
|
# the formatter on the host, which a pristine checkout cannot do.
|
||||||
|
# --no-cache for the same reason as script/docker: the gate phases the
|
||||||
|
# final stage depends on are RUN steps, and a cached one is a check that
|
||||||
|
# did not run.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build .
|
"$SCRIPT_DIR/bootstrap"
|
||||||
|
"$SCRIPT_DIR/check"
|
||||||
|
# Own line: a failing command substitution inside an argument does
|
||||||
|
# not trip `set -e`, so the inline form degrades silently to an
|
||||||
|
# empty constant. The VERSION build argument takes precedence over
|
||||||
|
# the version a build stage derives from the .git in the context.
|
||||||
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
|
[ -n "$version" ] || version="unknown"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$("$SCRIPT_DIR/projectname")" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -3,22 +3,21 @@
|
|||||||
# step fails, remove dist/ before returning its exit status. Our own extension
|
# step fails, remove dist/ before returning its exit status. Our own extension
|
||||||
# to scripts-to-rule-them-all, wrapped around every step of make build.
|
# to scripts-to-rule-them-all, wrapped around every step of make build.
|
||||||
#
|
#
|
||||||
# Why: with AUTISTMASK_DEBUG=1 exported in the calling shell, make build
|
# Why: with AUTISTMASK_DEBUG=1 exported, make build compiles a debug bundle and
|
||||||
# compiles a debug bundle and then fails on it in script/verify-build — but the
|
# then fails on it in script/verify-build, after the bundle is written. It is
|
||||||
# bundle is already written. It is loadable, and every wallet it creates gets
|
# loadable, and every wallet it creates gets the publicly committed test
|
||||||
# the publicly committed test recovery phrase from src/shared/constants.js. A
|
# recovery phrase from src/shared/constants.js, so a failed build must not leave
|
||||||
# failed release build that leaves that behind is a smaller version of the trap
|
# it behind. The removal is never silent: it says on stderr that dist/ is gone
|
||||||
# the verifier exists to close, and "the failure was loud" only works on an
|
# and why.
|
||||||
# operator who does not load dist/chrome/ anyway. Removing the artifact does not
|
|
||||||
# depend on that.
|
|
||||||
#
|
#
|
||||||
# Two things this deliberately does not do. It does not wrap make build-debug: a
|
# A failed check-censored --require-dist removes dist/ like any other step: a
|
||||||
# debug build that failed is not a mistakable artifact, and its output is the
|
# dist/ not cleared of the name RULES.md bars must not ship either. A step that
|
||||||
# evidence of what went wrong. And it never removes anything on a step that
|
# succeeds removes nothing. An interrupt (Ctrl-C) while a step runs removes
|
||||||
# SUCCEEDS, including the final check-censored --require-dist pass.
|
# nothing and says nothing, even when the step catches it and exits with a
|
||||||
#
|
# status of its own: the wrapper exits with 130 once the step has ended. An
|
||||||
# The removal is never silent: it says dist/ is gone and why, on stderr, above
|
# interrupt is not a build failure, and whoever interrupted the build knows it
|
||||||
# the build's own failure.
|
# did not finish. make build-debug is not wrapped: a debug build that failed is
|
||||||
|
# not a mistakable artifact, and its output is the evidence of what went wrong.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -66,12 +65,20 @@ main() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# An interrupt is not a build failure: once the step has ended, exit
|
||||||
|
# without removing anything, even if the step caught the interrupt and
|
||||||
|
# exited with a status of its own. bash as /bin/sh would otherwise carry on.
|
||||||
|
trap 'exit 130' INT
|
||||||
|
|
||||||
_status=0
|
_status=0
|
||||||
"$@" || _status=$?
|
"$@" || _status=$?
|
||||||
|
|
||||||
[ "$_status" -ne 0 ] || return 0
|
[ "$_status" -ne 0 ] || return 0
|
||||||
|
|
||||||
discard_dist
|
# A message that cannot be written, to a closed stderr or to a pipe nobody
|
||||||
|
# reads any more, must not replace the step's status.
|
||||||
|
trap '' PIPE
|
||||||
|
discard_dist || true
|
||||||
exit "$_status"
|
exit "$_status"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# Identical in all repos; the tag comes from script/projectname.
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
|
# --no-cache because the gate phases the final stage depends on are RUN
|
||||||
|
# steps, and a cached one is a check that did not run.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -8,7 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
# Own line: a failing command substitution inside an argument does
|
||||||
|
# not trip `set -e`, so the inline form degrades silently to an
|
||||||
|
# empty constant. The VERSION build argument takes precedence over
|
||||||
|
# the version a build stage derives from the .git in the context.
|
||||||
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
|
[ -n "$version" ] || version="unknown"
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg VERSION="$version" \
|
||||||
|
-t "$("$SCRIPT_DIR/projectname")" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+20
-1
@@ -4,10 +4,29 @@ set -eu
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# Must match the pin in script/bootstrap.
|
||||||
|
NODE_VERSION="22.17.0"
|
||||||
|
|
||||||
|
# script/bootstrap installs node and yarn under nvm and leaves neither
|
||||||
|
# on the PATH of the shell that called it, so resolve the pinned
|
||||||
|
# toolchain here the way bootstrap's own install step does. nvm is a
|
||||||
|
# bash script, hence the subshell.
|
||||||
|
run_yarn() {
|
||||||
|
if command -v yarn >/dev/null 2>&1; then
|
||||||
|
exec yarn "$@"
|
||||||
|
fi
|
||||||
|
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||||
|
echo "fmt: no yarn; run script/bootstrap first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
||||||
|
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
echo "Formatting..."
|
echo "Formatting..."
|
||||||
yarn run fmt 2>&1
|
run_yarn run fmt
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+20
-1
@@ -5,10 +5,29 @@ set -eu
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# Must match the pin in script/bootstrap.
|
||||||
|
NODE_VERSION="22.17.0"
|
||||||
|
|
||||||
|
# script/bootstrap installs node and yarn under nvm and leaves neither
|
||||||
|
# on the PATH of the shell that called it, so resolve the pinned
|
||||||
|
# toolchain here the way bootstrap's own install step does. nvm is a
|
||||||
|
# bash script, hence the subshell.
|
||||||
|
run_yarn() {
|
||||||
|
if command -v yarn >/dev/null 2>&1; then
|
||||||
|
exec yarn "$@"
|
||||||
|
fi
|
||||||
|
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
||||||
|
echo "fmt-check: no yarn; run script/bootstrap first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
||||||
|
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
echo "Checking formatting..."
|
echo "Checking formatting..."
|
||||||
yarn run fmt-check 2>&1
|
run_yarn run fmt-check
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
// the build when esbuild's own metafile reports src/shared/state.js as an input
|
// the build when esbuild's own metafile reports src/shared/state.js as an input
|
||||||
// of a background bundle. That consults the resolution esbuild actually
|
// of a background bundle. That consults the resolution esbuild actually
|
||||||
// performed, so no specifier syntax and no resolution rule can slip past it,
|
// performed, so no specifier syntax and no resolution rule can slip past it,
|
||||||
// and Dockerfile:42 runs `make build` in CI.
|
// and the Dockerfile's last stage runs `make build` in CI.
|
||||||
//
|
//
|
||||||
// What this rule is: fast local feedback, in the editor and in `make lint`,
|
// What this rule is: fast local feedback, in the editor and in `make lint`,
|
||||||
// before a full bundle. It reads sources from disk and matches import
|
// before a full bundle. It reads sources from disk and matches import
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Draws the toolbar icon and writes it to every file manifest/chrome.json
|
||||||
|
// declares under "icons". Run by `make icons`; tests/icons.test.js checks that
|
||||||
|
// the committed files hold exactly the image this draws.
|
||||||
|
//
|
||||||
|
// The icon is a dark-navy rounded square carrying a teal triangle with a
|
||||||
|
// smaller triangle cut out of it. Every coordinate below is a fraction of the
|
||||||
|
// icon's side, so one drawing serves every size. A pixel's colour is the
|
||||||
|
// average of an 8x8 grid of samples, one at the centre of each cell, which
|
||||||
|
// smooths the edges.
|
||||||
|
//
|
||||||
|
// The PNG is written here rather than by a library: RGBA at 8 bits per
|
||||||
|
// channel, filter type 0 (none) on every row, one IDAT chunk compressed by
|
||||||
|
// node's zlib at level 9. The committed files were compressed by stock zlib,
|
||||||
|
// which node's bundled zlib does not reproduce byte for byte, so the image is
|
||||||
|
// compared rather than the file: the IHDR and every pixel.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const zlib = require("zlib");
|
||||||
|
|
||||||
|
const { icons } = require("../../manifest/chrome.json");
|
||||||
|
|
||||||
|
const NAVY = [0x10, 0x1a, 0x2e];
|
||||||
|
const TEAL = [0x35, 0xe0, 0xc2];
|
||||||
|
const CORNER_RADIUS = 0.22;
|
||||||
|
const OUTER_TRIANGLE = [
|
||||||
|
[0.5, 0.15],
|
||||||
|
[0.115, 0.855],
|
||||||
|
[0.885, 0.855],
|
||||||
|
];
|
||||||
|
const INNER_TRIANGLE = [
|
||||||
|
[0.5, 0.4],
|
||||||
|
[0.29, 0.7],
|
||||||
|
[0.71, 0.7],
|
||||||
|
];
|
||||||
|
const SAMPLES_PER_SIDE = 8;
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = Buffer.from([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Points are in pixels from the icon's top-left corner.
|
||||||
|
function insideRoundedSquare(x, y, size) {
|
||||||
|
const r = CORNER_RADIUS * size;
|
||||||
|
// How far the point is past the start of a corner's curve, on each axis.
|
||||||
|
const dx = Math.max(r - x, 0, x - (size - r));
|
||||||
|
const dy = Math.max(r - y, 0, y - (size - r));
|
||||||
|
return dx * dx + dy * dy <= r * r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside or on an edge: the point is not on opposite sides of two edges.
|
||||||
|
function insideTriangle(x, y, [a, b, c]) {
|
||||||
|
const side = (p, q) =>
|
||||||
|
(q[0] - p[0]) * (y - p[1]) - (q[1] - p[1]) * (x - p[0]);
|
||||||
|
const sides = [side(a, b), side(b, c), side(c, a)];
|
||||||
|
return !(sides.some((s) => s < 0) && sides.some((s) => s > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rounds to the nearest integer and a half to the even neighbour, as the
|
||||||
|
// committed icons were made. Math.round takes a half up, which would change
|
||||||
|
// some pixels by one.
|
||||||
|
function roundHalfToEven(v) {
|
||||||
|
const down = Math.floor(v);
|
||||||
|
if (v - down !== 0.5) {
|
||||||
|
return Math.round(v);
|
||||||
|
}
|
||||||
|
return down % 2 === 0 ? down : down + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The RGBA bytes of the pixel whose top-left corner is (px, py).
|
||||||
|
function pixel(px, py, size, outer, inner) {
|
||||||
|
let inSquare = 0;
|
||||||
|
let inTeal = 0;
|
||||||
|
for (let j = 0; j < SAMPLES_PER_SIDE; j++) {
|
||||||
|
const y = py + (j + 0.5) / SAMPLES_PER_SIDE;
|
||||||
|
for (let i = 0; i < SAMPLES_PER_SIDE; i++) {
|
||||||
|
const x = px + (i + 0.5) / SAMPLES_PER_SIDE;
|
||||||
|
if (!insideRoundedSquare(x, y, size)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inSquare++;
|
||||||
|
if (insideTriangle(x, y, outer) && !insideTriangle(x, y, inner)) {
|
||||||
|
inTeal++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inSquare === 0) {
|
||||||
|
return [0, 0, 0, 0];
|
||||||
|
}
|
||||||
|
const colour = NAVY.map((navy, k) =>
|
||||||
|
roundHalfToEven(navy + ((TEAL[k] - navy) * inTeal) / inSquare),
|
||||||
|
);
|
||||||
|
const alpha = roundHalfToEven((255 * inSquare) / SAMPLES_PER_SIDE ** 2);
|
||||||
|
return [...colour, alpha];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A PNG chunk: the data's length, the type, the data, then the CRC-32 of the
|
||||||
|
// type and the data.
|
||||||
|
function chunk(type, data) {
|
||||||
|
const typeAndData = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(data.length);
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(zlib.crc32(typeAndData));
|
||||||
|
return Buffer.concat([length, typeAndData, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The complete PNG file for the icon at `size` pixels square.
|
||||||
|
function drawIcon(size) {
|
||||||
|
const toPixels = (corners) => corners.map(([x, y]) => [x * size, y * size]);
|
||||||
|
const outer = toPixels(OUTER_TRIANGLE);
|
||||||
|
const inner = toPixels(INNER_TRIANGLE);
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (let py = 0; py < size; py++) {
|
||||||
|
const row = [0]; // filter type 0: the row's bytes are stored as they are
|
||||||
|
for (let px = 0; px < size; px++) {
|
||||||
|
row.push(...pixel(px, py, size, outer, inner));
|
||||||
|
}
|
||||||
|
rows.push(Buffer.from(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = Buffer.alloc(13); // compression, filter, interlace: all 0
|
||||||
|
header.writeUInt32BE(size, 0); // width
|
||||||
|
header.writeUInt32BE(size, 4); // height
|
||||||
|
header[8] = 8; // bits per channel
|
||||||
|
header[9] = 6; // colour type: RGBA
|
||||||
|
|
||||||
|
return Buffer.concat([
|
||||||
|
PNG_SIGNATURE,
|
||||||
|
chunk("IHDR", header),
|
||||||
|
chunk("IDAT", zlib.deflateSync(Buffer.concat(rows), { level: 9 })),
|
||||||
|
chunk("IEND", Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The image in a PNG: its IHDR data, and its rows after decompression, each
|
||||||
|
// row's filter type byte first. With filter type 0 on every row, as drawIcon
|
||||||
|
// writes, the rows are the pixels themselves; a file using another filter does
|
||||||
|
// not match even where its pixels do. Refuses a file that is not a PNG, a chunk
|
||||||
|
// whose CRC-32 is wrong, and any chunk but IHDR, IDAT and IEND, rather than
|
||||||
|
// ignoring what it cannot compare.
|
||||||
|
function decodePng(png) {
|
||||||
|
if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
||||||
|
throw new Error("not a PNG");
|
||||||
|
}
|
||||||
|
let header = null;
|
||||||
|
const idat = [];
|
||||||
|
for (let pos = 8; pos < png.length; ) {
|
||||||
|
const length = png.readUInt32BE(pos);
|
||||||
|
const typeAndData = png.subarray(pos + 4, pos + 8 + length);
|
||||||
|
const type = typeAndData.toString("ascii", 0, 4);
|
||||||
|
if (zlib.crc32(typeAndData) !== png.readUInt32BE(pos + 8 + length)) {
|
||||||
|
throw new Error(`the ${type} chunk fails its CRC-32`);
|
||||||
|
}
|
||||||
|
if (type === "IHDR") {
|
||||||
|
header = typeAndData.subarray(4);
|
||||||
|
} else if (type === "IDAT") {
|
||||||
|
idat.push(typeAndData.subarray(4));
|
||||||
|
} else if (type !== "IEND") {
|
||||||
|
throw new Error(`unexpected ${type} chunk`);
|
||||||
|
}
|
||||||
|
pos += 12 + length;
|
||||||
|
}
|
||||||
|
if (header === null) {
|
||||||
|
throw new Error("no IHDR chunk");
|
||||||
|
}
|
||||||
|
return { header, rows: zlib.inflateSync(Buffer.concat(idat)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when `file` already holds the image in `png`. A file that is missing or
|
||||||
|
// cannot be read as a PNG does not, and is written over.
|
||||||
|
function holdsSameImage(file, png) {
|
||||||
|
let existing;
|
||||||
|
try {
|
||||||
|
existing = decodePng(fs.readFileSync(file));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const drawn = decodePng(png);
|
||||||
|
return (
|
||||||
|
existing.header.equals(drawn.header) && existing.rows.equals(drawn.rows)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file already holding the drawn image is left alone, so running this does
|
||||||
|
// not rewrite the committed icons with node's compression.
|
||||||
|
if (require.main === module) {
|
||||||
|
for (const [size, file] of Object.entries(icons)) {
|
||||||
|
const target = path.join(__dirname, "..", "..", file);
|
||||||
|
const png = drawIcon(Number(size));
|
||||||
|
if (holdsSameImage(target, png)) {
|
||||||
|
console.log(`icons: ${file} already holds this image`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(target, png);
|
||||||
|
console.log(`icons: wrote ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { drawIcon, decodePng };
|
||||||
+13
-41
@@ -1,51 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter (eslint, then prettier --check).
|
# script/lint: run the linter. Linting is a phase of the Dockerfile and
|
||||||
|
# this builds that phase alone; the linter is never installed or run on
|
||||||
|
# a developer host, where a shared result cache and a host-global lock
|
||||||
|
# make its answer untrustworthy.
|
||||||
#
|
#
|
||||||
# Linting is containerized. ESLint results depend on the ESLint version, and
|
# The phase is not the last stage in the file, so it is built only when
|
||||||
# the pinned one is the one in the image; a host's own install must not be
|
# --target names it. --no-cache because a cached lint layer is a lint
|
||||||
# able to decide whether this repo is green. From a host this therefore builds
|
# that did not run. The tag makes each build replace the previous image
|
||||||
# the Dockerfile's `lint` stage, which runs this same script inside the image.
|
# instead of leaving a dangling one behind.
|
||||||
#
|
|
||||||
# AUTISTMASK_LINT_NATIVE is set only in that image (see the Dockerfile) and is
|
|
||||||
# what stops the recursion, so `make check` inside the CI build lints in place
|
|
||||||
# instead of trying to reach a docker daemon it does not have.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
docker build --no-cache \
|
||||||
case "${AUTISTMASK_LINT_NATIVE:-}" in
|
--target lint \
|
||||||
1)
|
-t "$("$SCRIPT_DIR/projectname")-lint" .
|
||||||
echo "Linting..."
|
|
||||||
yarn run lint 2>&1
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
"") ;;
|
|
||||||
*)
|
|
||||||
# Set but not recognized: say so rather than silently taking the
|
|
||||||
# docker path, which would look like the variable had no effect.
|
|
||||||
echo "lint: AUTISTMASK_LINT_NATIVE is set to" \
|
|
||||||
"'${AUTISTMASK_LINT_NATIVE}'; the only recognized value is 1" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
|
||||||
echo "lint: docker is required; linting does not run on the host" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Linting in the pinned container..."
|
|
||||||
# --progress=plain: the default progress renderer collapses the lint
|
|
||||||
# output on success, and a lint run whose output cannot be seen is not
|
|
||||||
# evidence that it ran.
|
|
||||||
#
|
|
||||||
# --output=type=cacheonly: the exit status is the whole result; exporting
|
|
||||||
# an image afterwards costs about ten times the lint itself.
|
|
||||||
docker build --progress=plain --target lint \
|
|
||||||
--output=type=cacheonly . 2>&1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+10
-43
@@ -1,52 +1,19 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/test: run the test suite.
|
# script/test: run the test suite. Testing is a phase of the Dockerfile
|
||||||
#
|
# and this builds that phase alone, on the same terms as script/lint:
|
||||||
# jest runs three worker processes (package.json), not one per CPU core: on a
|
# --target because a phase that is not the last stage is built only when
|
||||||
# many-core shared host one per core took gigabytes of RAM per run.
|
# named, --no-cache because a cached test layer is a test that did not
|
||||||
#
|
# run, and a tag so each build replaces the previous image.
|
||||||
# The timeout bounds a hung suite; it is not a performance budget. On the busy
|
|
||||||
# shared build host the suite takes 8-13s with three workers, inside
|
|
||||||
# REPO_POLICIES' 20s budget. Inside the image the same suite also pays a cold
|
|
||||||
# jest cache and shares the runner with the rest of the build, which is not what
|
|
||||||
# that budget describes, so the Dockerfile raises the bound through
|
|
||||||
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
|
|
||||||
# produces a red that means nothing, and teaches "just run it again".
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
TIMEOUT="${AUTISTMASK_TEST_TIMEOUT:-30}"
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
echo "Running tests (timeout ${TIMEOUT}s)..."
|
docker build --no-cache \
|
||||||
|
--target test \
|
||||||
status=0
|
-t "$("$SCRIPT_DIR/projectname")-test" .
|
||||||
timeout "$TIMEOUT" yarn run test 2>&1 || status=$?
|
|
||||||
[ "$status" -eq 0 ] && return 0
|
|
||||||
|
|
||||||
# 124 is timeout(1) killing the suite. Say so: a kill is not a failed
|
|
||||||
# assertion, and the verbose rerun would only spend the same wall clock
|
|
||||||
# to be killed again.
|
|
||||||
if [ "$status" -eq 124 ]; then
|
|
||||||
echo "tests: TIMED OUT after ${TIMEOUT}s (no assertion failed)" >&2
|
|
||||||
echo "tests: raise AUTISTMASK_TEST_TIMEOUT if the suite is healthy" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 125 is timeout(1) itself failing, which here means AUTISTMASK_TEST_TIMEOUT
|
|
||||||
# is not a duration it accepts. The suite never ran, so it neither timed out
|
|
||||||
# nor failed, and the verbose rerun would only reprint the same complaint.
|
|
||||||
if [ "$status" -eq 125 ]; then
|
|
||||||
echo "tests: DID NOT RUN: timeout(1) rejected AUTISTMASK_TEST_TIMEOUT=\"${TIMEOUT}\"" >&2
|
|
||||||
echo "tests: set it to a duration such as 30 or 180 (see timeout(1))" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "--- Rerunning with --verbose for details ---"
|
|
||||||
timeout "$TIMEOUT" yarn run test:verbose 2>&1 || true
|
|
||||||
# Always fail: the first run already proved the tests are broken, so a
|
|
||||||
# flaky pass on the rerun must not turn the build green.
|
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+5
-2
@@ -4,7 +4,7 @@
|
|||||||
# scripts-to-rule-them-all.
|
# scripts-to-rule-them-all.
|
||||||
#
|
#
|
||||||
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
|
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
|
||||||
# caps make test at 20 seconds and a browser suite does not fit. Run it
|
# caps make test at 60 seconds and a browser suite does not fit. Run it
|
||||||
# yourself before touching popup views. ESLint's no-undef now catches a
|
# yourself before touching popup views. ESLint's no-undef now catches a
|
||||||
# used-but-not-imported identifier in make check, but only this suite sees
|
# used-but-not-imported identifier in make check, but only this suite sees
|
||||||
# what a view actually does when it runs.
|
# what a view actually does when it runs.
|
||||||
@@ -45,7 +45,10 @@ main() {
|
|||||||
trap 'cleanup; exit 130' INT TERM
|
trap 'cleanup; exit 130' INT TERM
|
||||||
|
|
||||||
echo "Building the Chrome e2e image (extension included)..."
|
echo "Building the Chrome e2e image (extension included)..."
|
||||||
docker build --iidfile "$IIDFILE" -t "$IMAGE" -f tests/e2e/Dockerfile .
|
# --no-cache: the image build runs make build and its checks, and a
|
||||||
|
# cached layer is a check that did not run.
|
||||||
|
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
|
||||||
|
-f tests/e2e/Dockerfile .
|
||||||
|
|
||||||
echo "Running e2e suite in the pinned Playwright container..."
|
echo "Running e2e suite in the pinned Playwright container..."
|
||||||
# The image is run by ID, not by tag: where two clones of this repo run
|
# The image is run by ID, not by tag: where two clones of this repo run
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# script/test-e2e. Our own extension to scripts-to-rule-them-all.
|
# script/test-e2e. Our own extension to scripts-to-rule-them-all.
|
||||||
#
|
#
|
||||||
# Deliberately NOT called by script/check or script/test, for the same
|
# Deliberately NOT called by script/check or script/test, for the same
|
||||||
# reason as the Chrome suite: REPO_POLICIES.md caps make test at 20 seconds
|
# reason as the Chrome suite: REPO_POLICIES.md caps make test at 60 seconds
|
||||||
# and a browser suite does not fit. .gitea/workflows/e2e.yml also runs it
|
# and a browser suite does not fit. .gitea/workflows/e2e.yml also runs it
|
||||||
# on every push, in a job separate from check.
|
# on every push, in a job separate from check.
|
||||||
#
|
#
|
||||||
@@ -44,7 +44,9 @@ main() {
|
|||||||
trap 'cleanup; exit 130' INT TERM
|
trap 'cleanup; exit 130' INT TERM
|
||||||
|
|
||||||
echo "Building the pinned Firefox e2e image (extension included)..."
|
echo "Building the pinned Firefox e2e image (extension included)..."
|
||||||
docker build --iidfile "$IIDFILE" -t "$IMAGE" \
|
# --no-cache: the image build runs make build and its checks, and a
|
||||||
|
# cached layer is a check that did not run.
|
||||||
|
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
|
||||||
-f tests/e2e/firefox/Dockerfile .
|
-f tests/e2e/firefox/Dockerfile .
|
||||||
|
|
||||||
echo "Running the Firefox e2e suite..."
|
echo "Running the Firefox e2e suite..."
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
# script/test-verify-build: exercise every failure mode of
|
# script/test-verify-build: exercise every failure mode of
|
||||||
# script/verify-build, and what make build does with dist/ after one of them
|
# script/verify-build, and what make build does with dist/ after one of them
|
||||||
# (script/discard-dist-on-failure). Our own extension to
|
# (script/discard-dist-on-failure). Our own extension to
|
||||||
# scripts-to-rule-them-all, run from script/check so make check covers it.
|
# scripts-to-rule-them-all, run by the Dockerfile's test phase so make check
|
||||||
|
# covers it.
|
||||||
#
|
#
|
||||||
# Why this exists: verify-build is the build-integrity guard, and four separate
|
# Why this exists: verify-build is the build-integrity guard, and four separate
|
||||||
# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation,
|
# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation,
|
||||||
@@ -914,6 +915,76 @@ run_cases() {
|
|||||||
discard_case "the wrapper given no command removes nothing" \
|
discard_case "the wrapper given no command removes nothing" \
|
||||||
c_control 1 kept "no command given" ""
|
c_control 1 kept "no command given" ""
|
||||||
|
|
||||||
|
# With stderr closed the wrapper cannot write its message, and must still
|
||||||
|
# remove dist/ and return the step's own status.
|
||||||
|
build_fixture
|
||||||
|
_status=0
|
||||||
|
(cd "$FIXTURE" &&
|
||||||
|
"$FIXTURE/script/discard-dist-on-failure" sh -c 'exit 6' 2>&-) ||
|
||||||
|
_status=$?
|
||||||
|
if [ "$_status" -eq 6 ] && [ ! -e "$FIXTURE/dist" ]; then
|
||||||
|
PASSED=$((PASSED + 1))
|
||||||
|
echo " ok: a failed step's status survives a closed stderr"
|
||||||
|
else
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
echo " FAIL: a failed step's status survives a closed stderr"
|
||||||
|
echo " exit status $_status, wanted 6, and dist/ must be gone"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The same with stderr a pipe nobody reads any more, where the write would
|
||||||
|
# kill the wrapper with SIGPIPE. The FIFO's only reader opens it, exits and
|
||||||
|
# is waited for before the wrapper runs, so the pipe never has a reader.
|
||||||
|
build_fixture
|
||||||
|
mkfifo "$WORK/stderr-fifo"
|
||||||
|
_status=0
|
||||||
|
(
|
||||||
|
: <"$WORK/stderr-fifo" &
|
||||||
|
exec 3>"$WORK/stderr-fifo"
|
||||||
|
wait "$!"
|
||||||
|
cd "$FIXTURE" &&
|
||||||
|
"$FIXTURE/script/discard-dist-on-failure" sh -c 'exit 6' 2>&3
|
||||||
|
) || _status=$?
|
||||||
|
if [ "$_status" -eq 6 ] && [ ! -e "$FIXTURE/dist" ]; then
|
||||||
|
PASSED=$((PASSED + 1))
|
||||||
|
echo " ok: a failed step's status survives a pipe nobody reads"
|
||||||
|
else
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
echo " FAIL: a failed step's status survives a pipe nobody reads"
|
||||||
|
echo " exit status $_status, wanted 6, and dist/ must be gone"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# An interrupt while a step runs removes nothing and says nothing, even
|
||||||
|
# when the step catches it and exits with a status of its own, as
|
||||||
|
# script/check-censored does. The step interrupts the wrapper and then
|
||||||
|
# itself, as Ctrl-C interrupts every process of the build at once. Run
|
||||||
|
# under dash and under bash, which /bin/sh may each be: bash carries on
|
||||||
|
# after such a step unless the wrapper stops it.
|
||||||
|
for _shell in dash bash; do
|
||||||
|
_name="an interrupt under $_shell removes nothing"
|
||||||
|
if ! command -v "$_shell" >/dev/null 2>&1; then
|
||||||
|
SKIPPED=$((SKIPPED + 1))
|
||||||
|
SKIPPED_NAMES="$SKIPPED_NAMES## - $_name ($_shell not found)$NEWLINE"
|
||||||
|
echo " SKIP ($_shell not found): $_name"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
build_fixture
|
||||||
|
_status=0
|
||||||
|
_out="$(cd "$FIXTURE" && "$_shell" \
|
||||||
|
"$FIXTURE/script/discard-dist-on-failure" \
|
||||||
|
sh -c 'trap "exit 4" INT; kill -INT "$PPID" $$; exit 5' 2>&1)" ||
|
||||||
|
_status=$?
|
||||||
|
if [ "$_status" -eq 130 ] && [ -z "$_out" ] &&
|
||||||
|
[ -f "$FIXTURE/dist/chrome/src/popup/index.js" ]; then
|
||||||
|
PASSED=$((PASSED + 1))
|
||||||
|
echo " ok: $_name"
|
||||||
|
else
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
echo " FAIL: $_name"
|
||||||
|
echo " exit status $_status, wanted 130; dist/ must be intact" \
|
||||||
|
"and nothing said. Output: $_out"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
check_makefile_wiring
|
check_makefile_wiring
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
+25
-22
@@ -207,12 +207,22 @@
|
|||||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- The error line sits beside Import, not above it: at
|
||||||
|
360x600 the button already starts near the bottom of
|
||||||
|
the popup, and a line of its own would push it below
|
||||||
|
the fold. The longest error fits on one line here. -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
id="btn-add-wallet-confirm"
|
id="btn-add-wallet-confirm"
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
>
|
>
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
|
<div
|
||||||
|
id="add-wallet-password-error"
|
||||||
|
class="text-xs min-h-[1.25rem] invisible"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
|
<!-- ============ MAIN VIEW: ALL WALLETS & ADDRESSES ============ -->
|
||||||
@@ -391,22 +401,11 @@
|
|||||||
></div>
|
></div>
|
||||||
<h2 class="font-bold mb-1">Export Private Key</h2>
|
<h2 class="font-bold mb-1">Export Private Key</h2>
|
||||||
<p class="text-xs mb-1" id="export-privkey-title"></p>
|
<p class="text-xs mb-1" id="export-privkey-title"></p>
|
||||||
<div class="text-xs mb-3">
|
<div id="export-privkey-address" class="text-xs mb-3"></div>
|
||||||
<span id="export-privkey-dot"></span>
|
|
||||||
<span
|
|
||||||
id="export-privkey-address"
|
|
||||||
class="cursor-pointer"
|
|
||||||
title="Click to copy"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs mb-3 text-muted">
|
<p class="text-xs mb-3 text-muted">
|
||||||
Warning: anyone with this private key can access and
|
Warning: anyone with this private key can access and
|
||||||
transfer all funds from this address. Never share it.
|
transfer all funds from this address. Never share it.
|
||||||
</p>
|
</p>
|
||||||
<div
|
|
||||||
id="export-privkey-flash"
|
|
||||||
class="text-xs mb-2 min-h-[1.25rem] invisible"
|
|
||||||
></div>
|
|
||||||
<div id="export-privkey-password-section" class="mb-2">
|
<div id="export-privkey-password-section" class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
@@ -415,9 +414,13 @@
|
|||||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
placeholder="Enter your password to continue"
|
placeholder="Enter your password to continue"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
id="export-privkey-password-error"
|
||||||
|
class="text-xs mt-2 mb-2 min-h-[1.25rem] invisible"
|
||||||
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-export-privkey-confirm"
|
id="btn-export-privkey-confirm"
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
>
|
>
|
||||||
Reveal
|
Reveal
|
||||||
</button>
|
</button>
|
||||||
@@ -1123,10 +1126,6 @@
|
|||||||
<strong id="delete-wallet-name"></strong> is permanent. Any
|
<strong id="delete-wallet-name"></strong> is permanent. Any
|
||||||
funds will be unrecoverable without your recovery phrase.
|
funds will be unrecoverable without your recovery phrase.
|
||||||
</p>
|
</p>
|
||||||
<div
|
|
||||||
id="delete-wallet-flash"
|
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
|
||||||
></div>
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
@@ -1136,6 +1135,10 @@
|
|||||||
placeholder="Enter your password to confirm"
|
placeholder="Enter your password to confirm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
id="delete-wallet-password-error"
|
||||||
|
class="text-xs mb-2 min-h-[1.25rem] invisible"
|
||||||
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-delete-wallet-confirm"
|
id="btn-delete-wallet-confirm"
|
||||||
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
@@ -1280,10 +1283,6 @@
|
|||||||
this wallet, from any device, without your password. Never
|
this wallet, from any device, without your password. Never
|
||||||
type them into a website and never show them to anyone.
|
type them into a website and never show them to anyone.
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
id="show-phrase-flash"
|
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
|
||||||
></div>
|
|
||||||
<div id="show-phrase-password-section" class="mb-2">
|
<div id="show-phrase-password-section" class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
@@ -1292,9 +1291,13 @@
|
|||||||
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
placeholder="Enter your password to continue"
|
placeholder="Enter your password to continue"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
id="show-phrase-password-error"
|
||||||
|
class="text-xs mt-2 mb-2 min-h-[1.25rem] invisible"
|
||||||
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-show-phrase-reveal"
|
id="btn-show-phrase-reveal"
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mt-2"
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
>
|
>
|
||||||
Reveal
|
Reveal
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ function init(ctx) {
|
|||||||
contractAddr,
|
contractAddr,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
log.infof("Adding token", info.symbol, contractAddr);
|
log.infof("Adding token", info.symbol, contractAddr);
|
||||||
state.trackedTokens.push({
|
state.trackedTokens.push({
|
||||||
@@ -68,6 +69,9 @@ function init(ctx) {
|
|||||||
}
|
}
|
||||||
require("./addressDetail").show();
|
require("./addressDetail").show();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
|
// src/popup/index.js.
|
||||||
|
if (ctx.pageClosed.aborted) return;
|
||||||
const detail = e.shortMessage || e.message || String(e);
|
const detail = e.shortMessage || e.message || String(e);
|
||||||
log.errorf("Adding token failed for", contractAddr, detail);
|
log.errorf("Adding token failed for", contractAddr, detail);
|
||||||
// lookupTokenInfo() rejects a contract with a one-line message
|
// lookupTokenInfo() rejects a contract with a one-line message
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
|
showError,
|
||||||
|
hideError,
|
||||||
goBack,
|
goBack,
|
||||||
clearViewStack,
|
clearViewStack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
@@ -98,6 +100,7 @@ function clear() {
|
|||||||
$("add-wallet-password").value = "";
|
$("add-wallet-password").value = "";
|
||||||
$("add-wallet-password-confirm").value = "";
|
$("add-wallet-password-confirm").value = "";
|
||||||
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
||||||
|
hideError("add-wallet-password-error");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each wallet has its own password (its own encryptedSecret), so adding a
|
// Each wallet has its own password (its own encryptedSecret), so adding a
|
||||||
@@ -125,15 +128,18 @@ function validatePassword() {
|
|||||||
const pw = $("add-wallet-password").value;
|
const pw = $("add-wallet-password").value;
|
||||||
const pw2 = $("add-wallet-password-confirm").value;
|
const pw2 = $("add-wallet-password-confirm").value;
|
||||||
if (!pw) {
|
if (!pw) {
|
||||||
showFlash("Please choose a password.");
|
showError("add-wallet-password-error", "Please choose a password.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (pw.length < 12) {
|
if (pw.length < 12) {
|
||||||
showFlash("Password must be at least 12 characters.");
|
showError(
|
||||||
|
"add-wallet-password-error",
|
||||||
|
"Password must be at least 12 characters.",
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (pw !== pw2) {
|
if (pw !== pw2) {
|
||||||
showFlash("Passwords do not match.");
|
showError("add-wallet-password-error", "Passwords do not match.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return pw;
|
return pw;
|
||||||
@@ -190,7 +196,12 @@ async function importMnemonic(ctx) {
|
|||||||
|
|
||||||
// Scan for used HD addresses beyond index 0.
|
// Scan for used HD addresses beyond index 0.
|
||||||
showFlash("Scanning for addresses...", 30000);
|
showFlash("Scanning for addresses...", 30000);
|
||||||
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
|
const scan = await scanForAddresses(
|
||||||
|
xpub,
|
||||||
|
state.rpcUrl,
|
||||||
|
state.networkId,
|
||||||
|
ctx.pageClosed,
|
||||||
|
);
|
||||||
if (scan.addresses.length > 1) {
|
if (scan.addresses.length > 1) {
|
||||||
wallet.addresses = scan.addresses.map((a) => ({
|
wallet.addresses = scan.addresses.map((a) => ({
|
||||||
address: a.address,
|
address: a.address,
|
||||||
@@ -300,7 +311,12 @@ async function importXprvKey(ctx) {
|
|||||||
|
|
||||||
// Scan for used HD addresses beyond index 0.
|
// Scan for used HD addresses beyond index 0.
|
||||||
showFlash("Scanning for addresses...", 30000);
|
showFlash("Scanning for addresses...", 30000);
|
||||||
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
|
const scan = await scanForAddresses(
|
||||||
|
xpub,
|
||||||
|
state.rpcUrl,
|
||||||
|
state.networkId,
|
||||||
|
ctx.pageClosed,
|
||||||
|
);
|
||||||
if (scan.addresses.length > 1) {
|
if (scan.addresses.length > 1) {
|
||||||
wallet.addresses = scan.addresses.map((a) => ({
|
wallet.addresses = scan.addresses.map((a) => ({
|
||||||
address: a.address,
|
address: a.address,
|
||||||
@@ -332,8 +348,10 @@ function init(ctx) {
|
|||||||
$("add-wallet-phrase-warning").style.visibility = "visible";
|
$("add-wallet-phrase-warning").style.visibility = "visible";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Import / confirm
|
// Import / confirm. Each press starts with no password error on screen:
|
||||||
|
// validatePassword() puts it back if the password is still wrong.
|
||||||
$("btn-add-wallet-confirm").addEventListener("click", async () => {
|
$("btn-add-wallet-confirm").addEventListener("click", async () => {
|
||||||
|
hideError("add-wallet-password-error");
|
||||||
if (currentMode === "mnemonic") {
|
if (currentMode === "mnemonic") {
|
||||||
await importMnemonic(ctx);
|
await importMnemonic(ctx);
|
||||||
} else if (currentMode === "privkey") {
|
} else if (currentMode === "privkey") {
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ async function loadTransactions(address) {
|
|||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
ensNameMap = new Map();
|
ensNameMap = new Map();
|
||||||
@@ -143,6 +144,9 @@ async function loadTransactions(address) {
|
|||||||
|
|
||||||
renderTransactions(txs);
|
renderTransactions(txs);
|
||||||
} 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("loadTransactions failed:", e.message);
|
log.errorf("loadTransactions failed:", e.message);
|
||||||
$("tx-list").innerHTML =
|
$("tx-list").innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ async function loadTransactions(address, tokenId) {
|
|||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
ensNameMap = new Map();
|
ensNameMap = new Map();
|
||||||
@@ -227,6 +228,9 @@ async function loadTransactions(address, tokenId) {
|
|||||||
|
|
||||||
renderTransactions(txs);
|
renderTransactions(txs);
|
||||||
} 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("loadTransactions failed:", e.message);
|
log.errorf("loadTransactions failed:", e.message);
|
||||||
$("address-token-tx-list").innerHTML =
|
$("address-token-tx-list").innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
|
showError,
|
||||||
|
hideError,
|
||||||
goBack,
|
goBack,
|
||||||
clearViewStack,
|
clearViewStack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
@@ -49,14 +51,13 @@ function confirmKey(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Drop the password from the DOM and the wallet selection from the
|
// Drop the password from the DOM and the wallet selection from the
|
||||||
// closure. Registered as the view-leave handler as well as run on entry,
|
// closure. Run by the view-leave handler as well as on entry, so the typed
|
||||||
// so the typed password does not sit in the hidden view after the user
|
// password does not sit in the hidden view after the user navigates away
|
||||||
// navigates away by any route, including the Settings gear.
|
// by any route, including the Settings gear.
|
||||||
function clear() {
|
function clear() {
|
||||||
deleteWalletIndex = null;
|
deleteWalletIndex = null;
|
||||||
$("delete-wallet-password").value = "";
|
$("delete-wallet-password").value = "";
|
||||||
$("delete-wallet-flash").textContent = "";
|
hideError("delete-wallet-password-error");
|
||||||
$("delete-wallet-flash").style.visibility = "hidden";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The lost-password screen holds no secret — a wallet name is not one —
|
// The lost-password screen holds no secret — a wallet name is not one —
|
||||||
@@ -147,8 +148,25 @@ async function finishDelete(walletIdx) {
|
|||||||
function init(_ctx) {
|
function init(_ctx) {
|
||||||
ctx = _ctx;
|
ctx = _ctx;
|
||||||
|
|
||||||
onViewLeave("delete-wallet-confirm", clear);
|
// Leaving drops the wallet selection, so each screen also comes off the
|
||||||
onViewLeave("delete-wallet-lost-password", clearLostPassword);
|
// Back stack, where the settings gear has just put it: Back from
|
||||||
|
// Settings must not land on a screen whose button can only answer "No
|
||||||
|
// wallet selected for deletion." A reopened popup drops them from the
|
||||||
|
// stack the same way (https://git.eeqj.de/sneak/AutistMask/issues/480).
|
||||||
|
onViewLeave("delete-wallet-confirm", () => {
|
||||||
|
clear();
|
||||||
|
const stack = state.viewStack;
|
||||||
|
if (stack[stack.length - 1] === "delete-wallet-confirm") {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onViewLeave("delete-wallet-lost-password", () => {
|
||||||
|
clearLostPassword();
|
||||||
|
const stack = state.viewStack;
|
||||||
|
if (stack[stack.length - 1] === "delete-wallet-lost-password") {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// No wipe here: goBack() routes through showView(), which runs the
|
// No wipe here: goBack() routes through showView(), which runs the
|
||||||
// leave hook.
|
// leave hook.
|
||||||
@@ -215,19 +233,22 @@ function init(_ctx) {
|
|||||||
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
|
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
|
||||||
const pw = $("delete-wallet-password").value;
|
const pw = $("delete-wallet-password").value;
|
||||||
if (!pw) {
|
if (!pw) {
|
||||||
$("delete-wallet-flash").textContent =
|
showError(
|
||||||
"Please enter your password.";
|
"delete-wallet-password-error",
|
||||||
$("delete-wallet-flash").style.visibility = "visible";
|
"Please enter your password.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deleteWalletIndex === null) {
|
if (deleteWalletIndex === null) {
|
||||||
$("delete-wallet-flash").textContent =
|
showError(
|
||||||
"No wallet selected for deletion.";
|
"delete-wallet-password-error",
|
||||||
$("delete-wallet-flash").style.visibility = "visible";
|
"No wallet selected for deletion.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hideError("delete-wallet-password-error");
|
||||||
const btn = $("btn-delete-wallet-confirm");
|
const btn = $("btn-delete-wallet-confirm");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add("text-muted");
|
btn.classList.add("text-muted");
|
||||||
@@ -239,9 +260,10 @@ function init(_ctx) {
|
|||||||
try {
|
try {
|
||||||
await decryptWithPassword(wallet.encryptedSecret, pw);
|
await decryptWithPassword(wallet.encryptedSecret, pw);
|
||||||
} catch {
|
} catch {
|
||||||
$("delete-wallet-flash").textContent =
|
showError(
|
||||||
"That password is incorrect. Please try again.";
|
"delete-wallet-password-error",
|
||||||
$("delete-wallet-flash").style.visibility = "visible";
|
"That password is incorrect. Please try again.",
|
||||||
|
);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.classList.remove("text-muted");
|
btn.classList.remove("text-muted");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
|
showError,
|
||||||
|
hideError,
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
goBack,
|
goBack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
@@ -56,11 +58,6 @@ function isCurrentReveal(generation) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(message) {
|
|
||||||
$("export-privkey-flash").textContent = message;
|
|
||||||
$("export-privkey-flash").style.visibility = "visible";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wipe every trace of the key and drop the address selection. Safe to call
|
// Wipe every trace of the key and drop the address selection. Safe to call
|
||||||
// when nothing was ever revealed, and safe to call twice.
|
// when nothing was ever revealed, and safe to call twice.
|
||||||
function clear() {
|
function clear() {
|
||||||
@@ -71,8 +68,7 @@ function clear() {
|
|||||||
$("export-privkey-password").value = "";
|
$("export-privkey-password").value = "";
|
||||||
$("export-privkey-result").classList.add("hidden");
|
$("export-privkey-result").classList.add("hidden");
|
||||||
$("export-privkey-password-section").classList.remove("hidden");
|
$("export-privkey-password-section").classList.remove("hidden");
|
||||||
$("export-privkey-flash").textContent = "";
|
hideError("export-privkey-password-error");
|
||||||
$("export-privkey-flash").style.visibility = "hidden";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(walletIdx, addrIdx) {
|
function show(walletIdx, addrIdx) {
|
||||||
@@ -98,7 +94,7 @@ function show(walletIdx, addrIdx) {
|
|||||||
|
|
||||||
$("export-privkey-title").textContent =
|
$("export-privkey-title").textContent =
|
||||||
wallet.name + " — Address " + (addrIdx + 1);
|
wallet.name + " — Address " + (addrIdx + 1);
|
||||||
const addrContainer = $("export-privkey-dot").parentElement;
|
const addrContainer = $("export-privkey-address");
|
||||||
addrContainer.innerHTML = renderAddressHtml(addr.address);
|
addrContainer.innerHTML = renderAddressHtml(addr.address);
|
||||||
attachCopyHandlers(addrContainer);
|
attachCopyHandlers(addrContainer);
|
||||||
|
|
||||||
@@ -112,15 +108,19 @@ function show(walletIdx, addrIdx) {
|
|||||||
async function reveal() {
|
async function reveal() {
|
||||||
const password = $("export-privkey-password").value;
|
const password = $("export-privkey-password").value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
fail("Please enter your password.");
|
showError(
|
||||||
|
"export-privkey-password-error",
|
||||||
|
"Please enter your password.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (walletIndex === null) {
|
if (walletIndex === null) {
|
||||||
fail("No address is selected.");
|
showError("export-privkey-password-error", "No address is selected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wallet = state.wallets[walletIndex];
|
const wallet = state.wallets[walletIndex];
|
||||||
|
|
||||||
|
hideError("export-privkey-password-error");
|
||||||
const btn = $("btn-export-privkey-confirm");
|
const btn = $("btn-export-privkey-confirm");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add("text-muted");
|
btn.classList.add("text-muted");
|
||||||
@@ -140,11 +140,12 @@ async function reveal() {
|
|||||||
$("export-privkey-password-section").classList.add("hidden");
|
$("export-privkey-password-section").classList.add("hidden");
|
||||||
$("export-privkey-value").textContent = signer.privateKey;
|
$("export-privkey-value").textContent = signer.privateKey;
|
||||||
$("export-privkey-result").classList.remove("hidden");
|
$("export-privkey-result").classList.remove("hidden");
|
||||||
$("export-privkey-flash").textContent = "";
|
|
||||||
$("export-privkey-flash").style.visibility = "hidden";
|
|
||||||
} catch {
|
} catch {
|
||||||
if (!isCurrentReveal(generation)) return;
|
if (!isCurrentReveal(generation)) return;
|
||||||
fail("That password is incorrect. Please try again.");
|
showError(
|
||||||
|
"export-privkey-password-error",
|
||||||
|
"That password is incorrect. Please try again.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.classList.remove("text-muted");
|
btn.classList.remove("text-muted");
|
||||||
@@ -152,7 +153,16 @@ async function reveal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
onViewLeave(VIEW, clear);
|
// Leaving drops the address selection, so the screen also comes off the
|
||||||
|
// Back stack, where the settings gear has just put it: Back from Settings
|
||||||
|
// must not land on a password prompt that can only fail. A reopened popup
|
||||||
|
// drops it from the stack the same way
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
|
||||||
|
onViewLeave(VIEW, () => {
|
||||||
|
clear();
|
||||||
|
const stack = state.viewStack;
|
||||||
|
if (stack[stack.length - 1] === VIEW) stack.pop();
|
||||||
|
});
|
||||||
|
|
||||||
// No wipe here: goBack() routes through showView(), which runs the
|
// No wipe here: goBack() routes through showView(), which runs the
|
||||||
// leave hook. A per-button wipe would only cover this one path.
|
// leave hook. A per-button wipe would only cover this one path.
|
||||||
|
|||||||
@@ -216,10 +216,21 @@ function pushCurrentView() {
|
|||||||
|
|
||||||
// Pop the navigation stack and show the previous view. If the stack
|
// Pop the navigation stack and show the previous view. If the stack
|
||||||
// is empty, fall back to the main (home) view.
|
// is empty, fall back to the main (home) view.
|
||||||
|
//
|
||||||
|
// An entry for the view already showing is skipped: landing on it would
|
||||||
|
// make Back seem to do nothing. Settings, the recovery phrase or delete
|
||||||
|
// wallet screen, then the gear leaves Settings under Settings, because that
|
||||||
|
// screen takes itself off the stack when left, and a reopened popup cuts it
|
||||||
|
// off the restored stack the same way
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/481).
|
||||||
function goBack() {
|
function goBack() {
|
||||||
|
const stack = state.viewStack;
|
||||||
|
while (stack.length > 0 && stack[stack.length - 1] === state.currentView) {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
let target;
|
let target;
|
||||||
if (state.viewStack.length > 0) {
|
if (stack.length > 0) {
|
||||||
target = state.viewStack.pop();
|
target = stack.pop();
|
||||||
} else {
|
} else {
|
||||||
target = "main";
|
target = "main";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,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) {
|
||||||
|
|||||||
@@ -292,6 +292,9 @@ async function fillMaxAmount() {
|
|||||||
]);
|
]);
|
||||||
feeWei = feeReserveWei(gasLimit, feeData);
|
feeWei = feeReserveWei(gasLimit, feeData);
|
||||||
} 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(
|
log.errorf(
|
||||||
"max amount fee estimate failed:",
|
"max amount fee estimate failed:",
|
||||||
e.shortMessage || e.message,
|
e.shortMessage || e.message,
|
||||||
|
|||||||
@@ -273,6 +273,9 @@ function init(ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
|
// src/popup/index.js.
|
||||||
|
if (ctx.pageClosed.aborted) return;
|
||||||
// Not the error's message: fetch puts the whole URL, password and
|
// Not the error's message: fetch puts the whole URL, password and
|
||||||
// key included, in the message of the error it throws for a URL
|
// key included, in the message of the error it throws for a URL
|
||||||
// with a user name and password or one it cannot parse.
|
// with a user name and password or one it cannot parse.
|
||||||
@@ -299,6 +302,7 @@ function init(ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if (ctx.pageClosed.aborted) return;
|
||||||
// Not the error's message, as for the RPC check above.
|
// Not the error's message, as for the RPC check above.
|
||||||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ function init(_ctx) {
|
|||||||
addr,
|
addr,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
log.infof("Adding token", info.symbol, addr);
|
log.infof("Adding token", info.symbol, addr);
|
||||||
state.trackedTokens.push({
|
state.trackedTokens.push({
|
||||||
@@ -152,6 +153,9 @@ function init(_ctx) {
|
|||||||
renderDropdown();
|
renderDropdown();
|
||||||
ctx.doRefreshAndRender();
|
ctx.doRefreshAndRender();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
|
// src/popup/index.js.
|
||||||
|
if (ctx.pageClosed.aborted) return;
|
||||||
const detail = e.shortMessage || e.message || String(e);
|
const detail = e.shortMessage || e.message || String(e);
|
||||||
log.errorf("Adding token failed for", addr, detail);
|
log.errorf("Adding token failed for", addr, detail);
|
||||||
// lookupTokenInfo() rejects a contract with a one-line message
|
// lookupTokenInfo() rejects a contract with a one-line message
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
|
showError,
|
||||||
|
hideError,
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
goBack,
|
goBack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
@@ -52,11 +54,6 @@ function isCurrentReveal(generation) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(message) {
|
|
||||||
$("show-phrase-flash").textContent = message;
|
|
||||||
$("show-phrase-flash").style.visibility = "visible";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wipe every trace of the phrase and drop the wallet selection. Safe to
|
// Wipe every trace of the phrase and drop the wallet selection. Safe to
|
||||||
// call when nothing was ever revealed, and safe to call twice.
|
// call when nothing was ever revealed, and safe to call twice.
|
||||||
function clear() {
|
function clear() {
|
||||||
@@ -66,8 +63,7 @@ function clear() {
|
|||||||
$("show-phrase-password").value = "";
|
$("show-phrase-password").value = "";
|
||||||
$("show-phrase-result").classList.add("hidden");
|
$("show-phrase-result").classList.add("hidden");
|
||||||
$("show-phrase-password-section").classList.remove("hidden");
|
$("show-phrase-password-section").classList.remove("hidden");
|
||||||
$("show-phrase-flash").textContent = "";
|
hideError("show-phrase-password-error");
|
||||||
$("show-phrase-flash").style.visibility = "hidden";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(walletIdx) {
|
function show(walletIdx) {
|
||||||
@@ -90,19 +86,23 @@ function show(walletIdx) {
|
|||||||
async function reveal() {
|
async function reveal() {
|
||||||
const password = $("show-phrase-password").value;
|
const password = $("show-phrase-password").value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
fail("Please enter your password.");
|
showError("show-phrase-password-error", "Please enter your password.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (walletIndex === null) {
|
if (walletIndex === null) {
|
||||||
fail("No wallet is selected.");
|
showError("show-phrase-password-error", "No wallet is selected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wallet = state.wallets[walletIndex];
|
const wallet = state.wallets[walletIndex];
|
||||||
if (!walletHasRecoveryPhrase(wallet)) {
|
if (!walletHasRecoveryPhrase(wallet)) {
|
||||||
fail("This wallet does not have a recovery phrase.");
|
showError(
|
||||||
|
"show-phrase-password-error",
|
||||||
|
"This wallet does not have a recovery phrase.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hideError("show-phrase-password-error");
|
||||||
const btn = $("btn-show-phrase-reveal");
|
const btn = $("btn-show-phrase-reveal");
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add("text-muted");
|
btn.classList.add("text-muted");
|
||||||
@@ -120,13 +120,14 @@ async function reveal() {
|
|||||||
$("show-phrase-password-section").classList.add("hidden");
|
$("show-phrase-password-section").classList.add("hidden");
|
||||||
$("show-phrase-value").textContent = phrase;
|
$("show-phrase-value").textContent = phrase;
|
||||||
$("show-phrase-result").classList.remove("hidden");
|
$("show-phrase-result").classList.remove("hidden");
|
||||||
$("show-phrase-flash").textContent = "";
|
|
||||||
$("show-phrase-flash").style.visibility = "hidden";
|
|
||||||
} catch {
|
} catch {
|
||||||
if (!isCurrentReveal(generation)) return;
|
if (!isCurrentReveal(generation)) return;
|
||||||
// Deliberately not the caught error: the message is fixed so that
|
// Deliberately not the caught error: the message is fixed so that
|
||||||
// nothing derived from the ciphertext or the attempt can surface.
|
// nothing derived from the ciphertext or the attempt can surface.
|
||||||
fail("That password is incorrect. Please try again.");
|
showError(
|
||||||
|
"show-phrase-password-error",
|
||||||
|
"That password is incorrect. Please try again.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.classList.remove("text-muted");
|
btn.classList.remove("text-muted");
|
||||||
@@ -134,7 +135,16 @@ async function reveal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
onViewLeave(VIEW, clear);
|
// Leaving drops the wallet selection, so the screen also comes off the
|
||||||
|
// Back stack, where the settings gear has just put it: Back from Settings
|
||||||
|
// must not land on a password prompt that can only fail. A reopened popup
|
||||||
|
// drops it from the stack the same way
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
|
||||||
|
onViewLeave(VIEW, () => {
|
||||||
|
clear();
|
||||||
|
const stack = state.viewStack;
|
||||||
|
if (stack[stack.length - 1] === VIEW) stack.pop();
|
||||||
|
});
|
||||||
|
|
||||||
$("btn-show-phrase-back").addEventListener("click", () => {
|
$("btn-show-phrase-back").addEventListener("click", () => {
|
||||||
goBack();
|
goBack();
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
try {
|
try {
|
||||||
receipt = await provider.getTransactionReceipt(txHash);
|
receipt = await provider.getTransactionReceipt(txHash);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
|
// src/popup/index.js.
|
||||||
|
if (ctx.pageClosed.aborted) return;
|
||||||
// A thrown lookup means "no answer this tick", not "no
|
// A thrown lookup means "no answer this tick", not "no
|
||||||
// receipt": the RPC failed, the chain said nothing. Declaring
|
// receipt": the RPC failed, the chain said nothing. Declaring
|
||||||
// the timeout off it would report a confirmed transaction as
|
// the timeout off it would report a confirmed transaction as
|
||||||
|
|||||||
+34
-6
@@ -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;
|
||||||
@@ -279,7 +294,8 @@ async function refreshBalances(
|
|||||||
|
|
||||||
// Look up token metadata from its contract.
|
// Look up token metadata from its contract.
|
||||||
// Calls symbol() and decimals() to verify it implements ERC-20.
|
// Calls symbol() and decimals() to verify it implements ERC-20.
|
||||||
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
// `signal` is as for fetchTokenBalances().
|
||||||
|
async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
|
||||||
log.debugf("lookupTokenInfo", contractAddress, "rpc:", urlOrigin(rpcUrl));
|
log.debugf("lookupTokenInfo", contractAddress, "rpc:", urlOrigin(rpcUrl));
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
||||||
@@ -289,7 +305,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
|||||||
symbol = await contract.symbol();
|
symbol = await contract.symbol();
|
||||||
log.debugf("symbol() =", symbol);
|
log.debugf("symbol() =", symbol);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!signal?.aborted) {
|
||||||
log.errorf("symbol() failed:", e.shortMessage || e.message);
|
log.errorf("symbol() failed:", e.shortMessage || e.message);
|
||||||
|
}
|
||||||
throw new Error("Not a valid ERC-20 token (symbol() failed).");
|
throw new Error("Not a valid ERC-20 token (symbol() failed).");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +315,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
|||||||
decimals = await contract.decimals();
|
decimals = await contract.decimals();
|
||||||
log.debugf("decimals() =", decimals);
|
log.debugf("decimals() =", decimals);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!signal?.aborted) {
|
||||||
log.errorf("decimals() failed:", e.shortMessage || e.message);
|
log.errorf("decimals() failed:", e.shortMessage || e.message);
|
||||||
|
}
|
||||||
throw new Error("Not a valid ERC-20 token (decimals() failed).");
|
throw new Error("Not a valid ERC-20 token (decimals() failed).");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,16 +325,21 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
|||||||
name = await contract.name();
|
name = await contract.name();
|
||||||
log.debugf("name() =", name);
|
log.debugf("name() =", name);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!signal?.aborted) {
|
||||||
log.warnf(
|
log.warnf(
|
||||||
"name() failed, using symbol as name:",
|
"name() failed, using symbol as name:",
|
||||||
e.shortMessage || e.message,
|
e.shortMessage || e.message,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
name = symbol;
|
name = symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate to prevent storage of excessively long values from RPC
|
// Truncate to prevent storage of excessively long values from RPC.
|
||||||
name = String(name).slice(0, 64);
|
// Counted in code points, as displaySymbol() counts them, so the cut never
|
||||||
symbol = String(symbol).slice(0, 12);
|
// falls between the two halves of an emoji and stores a half that renders
|
||||||
|
// as U+FFFD.
|
||||||
|
name = Array.from(String(name)).slice(0, 64).join("");
|
||||||
|
symbol = Array.from(String(symbol)).slice(0, 12).join("");
|
||||||
|
|
||||||
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
|
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
|
||||||
return { name, symbol, decimals: Number(decimals) };
|
return { name, symbol, decimals: Number(decimals) };
|
||||||
@@ -324,7 +349,8 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
|||||||
// Checks gapLimit addresses in parallel per batch. Stops when an entire
|
// Checks gapLimit addresses in parallel per batch. Stops when an entire
|
||||||
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
|
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
|
||||||
// Returns { addresses: [{ address, index }], nextIndex }.
|
// Returns { addresses: [{ address, index }], nextIndex }.
|
||||||
async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
|
// `signal` is as for fetchTokenBalances().
|
||||||
|
async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) {
|
||||||
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
|
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
const used = [];
|
const used = [];
|
||||||
@@ -347,11 +373,13 @@ async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
|
|||||||
]);
|
]);
|
||||||
return { addr, index, isUsed: balance > 0n || txCount > 0 };
|
return { addr, index, isUsed: balance > 0n || txCount > 0 };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!signal?.aborted) {
|
||||||
log.errorf(
|
log.errorf(
|
||||||
"scanForAddresses check failed",
|
"scanForAddresses check failed",
|
||||||
addr,
|
addr,
|
||||||
e.shortMessage || e.message,
|
e.shortMessage || e.message,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return { addr, index, isUsed: false };
|
return { addr, index, isUsed: false };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
+9
-3
@@ -32,7 +32,8 @@ function setCache(address, name) {
|
|||||||
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
|
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveEnsName(address, rpcUrl, networkId) {
|
// `signal` is as for fetchTokenBalances() in src/shared/balances.js.
|
||||||
|
async function resolveEnsName(address, rpcUrl, networkId, signal) {
|
||||||
const cached = getCached(address);
|
const cached = getCached(address);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
@@ -42,21 +43,26 @@ async function resolveEnsName(address, rpcUrl, networkId) {
|
|||||||
setCache(address, name);
|
setCache(address, name);
|
||||||
return name;
|
return name;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!signal?.aborted) {
|
||||||
log.errorf(
|
log.errorf(
|
||||||
"ENS reverse lookup failed",
|
"ENS reverse lookup failed",
|
||||||
address,
|
address,
|
||||||
e.shortMessage || e.message,
|
e.shortMessage || e.message,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
// Don't cache failures — let subsequent lookups retry
|
// Don't cache failures — let subsequent lookups retry
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveEnsNames(addresses, rpcUrl, networkId) {
|
async function resolveEnsNames(addresses, rpcUrl, networkId, signal) {
|
||||||
const results = new Map();
|
const results = new Map();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
addresses.map(async (addr) => {
|
addresses.map(async (addr) => {
|
||||||
results.set(addr, await resolveEnsName(addr, rpcUrl, networkId));
|
results.set(
|
||||||
|
addr,
|
||||||
|
await resolveEnsName(addr, rpcUrl, networkId, signal),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Creating a wallet from a recovery phrase or an extended private key does not
|
||||||
|
// report an address scan 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/475).
|
||||||
|
//
|
||||||
|
// In the popup a cancelled request fails with the same "Failed to fetch" as a
|
||||||
|
// server that cannot be reached, so every RPC request here fails that way, and
|
||||||
|
// only the signal the popup aborts on pagehide tells the two cases apart.
|
||||||
|
// Driven against the fake elements tests/tokenLookupCancelled.test.js uses.
|
||||||
|
|
||||||
|
const { FetchRequest, HDNodeWallet, Mnemonic } = require("ethers");
|
||||||
|
|
||||||
|
const PHRASE =
|
||||||
|
"abandon abandon abandon abandon abandon abandon " +
|
||||||
|
"abandon abandon abandon abandon abandon about";
|
||||||
|
const PASSWORD = "correct horse battery staple";
|
||||||
|
|
||||||
|
let elements;
|
||||||
|
|
||||||
|
function fakeElement() {
|
||||||
|
return {
|
||||||
|
value: "",
|
||||||
|
textContent: "",
|
||||||
|
style: {},
|
||||||
|
classList: { toggle: () => {} },
|
||||||
|
listeners: {},
|
||||||
|
addEventListener(event, handler) {
|
||||||
|
this.listeners[event] = handler;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stands in for document.getElementById(): one fake element per id.
|
||||||
|
function element(id) {
|
||||||
|
return (elements[id] ||= fakeElement());
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.doMock("../src/popup/views/helpers", () => ({
|
||||||
|
$: element,
|
||||||
|
showView: () => {},
|
||||||
|
showFlash: () => {},
|
||||||
|
showError: () => {},
|
||||||
|
hideError: () => {},
|
||||||
|
goBack: () => {},
|
||||||
|
clearViewStack: () => {},
|
||||||
|
onViewLeave: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// state.js reads chrome.storage.local at load, and saveState() writes it.
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const addWallet = require("../src/popup/views/addWallet");
|
||||||
|
|
||||||
|
let logged;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
elements = {};
|
||||||
|
logged = [];
|
||||||
|
state.wallets = [];
|
||||||
|
state.rpcUrl = "https://rpc.example.invalid";
|
||||||
|
state.networkId = "mainnet";
|
||||||
|
for (const method of ["warn", "error"]) {
|
||||||
|
jest.spyOn(console, method).mockImplementation((...args) => {
|
||||||
|
logged.push(args.map(String).join(" "));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// What the scan found is logged at info level, which is not under test.
|
||||||
|
jest.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
FetchRequest.registerGetUrl(async () => {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
["a recovery phrase", "mnemonic", "wallet-mnemonic", PHRASE],
|
||||||
|
[
|
||||||
|
"an extended private key",
|
||||||
|
"xprv",
|
||||||
|
"import-xprv-key",
|
||||||
|
HDNodeWallet.fromSeed(Mnemonic.fromPhrase(PHRASE).computeSeed())
|
||||||
|
.extendedKey,
|
||||||
|
],
|
||||||
|
])("creating a wallet from %s", (_name, mode, field, secret) => {
|
||||||
|
// Enters `secret` on the add-wallet screen, presses its button and waits
|
||||||
|
// for the address scan that follows.
|
||||||
|
async function create(pageClosed) {
|
||||||
|
addWallet.init({
|
||||||
|
renderWalletList: () => {},
|
||||||
|
doRefreshAndRender: () => {},
|
||||||
|
pageClosed,
|
||||||
|
});
|
||||||
|
element("tab-" + mode).listeners.click();
|
||||||
|
element(field).value = secret;
|
||||||
|
element("add-wallet-password").value = PASSWORD;
|
||||||
|
element("add-wallet-password-confirm").value = PASSWORD;
|
||||||
|
await element("btn-add-wallet-confirm").listeners.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a scan failure while the popup is open is reported", async () => {
|
||||||
|
await create(new AbortController().signal);
|
||||||
|
expect(state.wallets).toHaveLength(1);
|
||||||
|
expect(logged).not.toEqual([]);
|
||||||
|
for (const line of logged) {
|
||||||
|
expect(line).toContain("scanForAddresses check failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a scan failure once the popup has closed is not", async () => {
|
||||||
|
const pageClosed = new AbortController();
|
||||||
|
pageClosed.abort();
|
||||||
|
await create(pageClosed.signal);
|
||||||
|
expect(state.wallets).toHaveLength(1);
|
||||||
|
expect(logged).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,6 +52,7 @@ const {
|
|||||||
resetRenderedViews,
|
resetRenderedViews,
|
||||||
} = require("../src/popup/viewRouter");
|
} = require("../src/popup/viewRouter");
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
|
const { restorableStack } = require("../src/shared/persistedState");
|
||||||
|
|
||||||
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||||
const TOKEN = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
const TOKEN = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||||
@@ -209,6 +210,23 @@ describe("Back onto a view the reopened popup never rendered", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/481. Settings, the recovery
|
||||||
|
// phrase or delete wallet screen, the gear, then a reopen: the restored stack
|
||||||
|
// is cut at the screen the gear left, which leaves Settings under the Settings
|
||||||
|
// the popup reopens onto.
|
||||||
|
describe("Back from Settings reopened over its own entry", () => {
|
||||||
|
test.each(["show-phrase", "delete-wallet-confirm"])(
|
||||||
|
"goes to the screen under it after leaving %s",
|
||||||
|
(left) => {
|
||||||
|
const stored = ["main", "settings", left];
|
||||||
|
reopenedOn("settings", restorableStack(stored, "settings"));
|
||||||
|
goBack();
|
||||||
|
expect(calls).toEqual(["main"]);
|
||||||
|
expect(state.currentView).toBe("main");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// The guards are restoreView()'s, so a popped view whose backing data is
|
// The guards are restoreView()'s, so a popped view whose backing data is
|
||||||
// gone lands on Home rather than on an empty template.
|
// gone lands on Home rather than on an empty template.
|
||||||
describe("Back onto a view whose backing data is gone", () => {
|
describe("Back onto a view whose backing data is gone", () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//
|
//
|
||||||
// The browser half of the same claim — that a real Chrome renders that
|
// The browser half of the same claim — that a real Chrome renders that
|
||||||
// string as text and puts no iframe in the popup DOM — is in
|
// string as text and puts no iframe in the popup DOM — is in
|
||||||
// tests/e2e/run.js. This half runs inside the 20-second make test cap.
|
// tests/e2e/run.js. This half runs inside the 60-second make test cap.
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// The balance refresh, and the address scan after a wallet is created, do not
|
||||||
|
// report a request the popup's own closing cancelled, and still report one
|
||||||
|
// that failed while the popup was open
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/218,
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/475).
|
||||||
|
//
|
||||||
|
// 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, scanForAddresses } = require("../src/shared/balances");
|
||||||
|
const {
|
||||||
|
generateMnemonic,
|
||||||
|
hdWalletFromMnemonic,
|
||||||
|
} = require("../src/shared/wallet");
|
||||||
|
|
||||||
|
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 scan's balance and
|
||||||
|
// transaction count) 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([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function scan(signal) {
|
||||||
|
// What the scan found is logged at info level, which is not under test.
|
||||||
|
jest.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const { xpub } = hdWalletFromMnemonic(generateMnemonic());
|
||||||
|
return scanForAddresses(xpub, RPC_URL, "mainnet", signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a scan failure while the popup is open is reported", async () => {
|
||||||
|
await scan(new AbortController().signal);
|
||||||
|
expect(
|
||||||
|
logged.some((line) => line.includes("scanForAddresses check failed")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a scan failure once the popup has closed is not", async () => {
|
||||||
|
const pageClosed = new AbortController();
|
||||||
|
pageClosed.abort();
|
||||||
|
await scan(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);
|
||||||
|
});
|
||||||
@@ -253,7 +253,7 @@ describe("reaching the screen", () => {
|
|||||||
const { decryptWithPassword } = require("../src/shared/vault");
|
const { decryptWithPassword } = require("../src/shared/vault");
|
||||||
decryptWithPassword.mockRejectedValue(new Error("nope"));
|
decryptWithPassword.mockRejectedValue(new Error("nope"));
|
||||||
await click("btn-delete-wallet-confirm");
|
await click("btn-delete-wallet-confirm");
|
||||||
expect(node("delete-wallet-flash").textContent).toBe(
|
expect(node("delete-wallet-password-error").textContent).toBe(
|
||||||
"That password is incorrect. Please try again.",
|
"That password is incorrect. Please try again.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -615,3 +615,46 @@ describe("the password route's confirm button", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/480: leaving either delete
|
||||||
|
// screen drops its wallet selection, so Back onto one showed a screen whose
|
||||||
|
// button could only answer "No wallet selected for deletion." Taking the
|
||||||
|
// screen off the stack leaves Settings under Settings, and Back must not land
|
||||||
|
// there either (https://git.eeqj.de/sneak/AutistMask/issues/481).
|
||||||
|
describe("Back from Settings after leaving by the settings gear", () => {
|
||||||
|
test("goes past the delete screen to the screen under Settings", () => {
|
||||||
|
const { helpers, deleteWallet, state } = load();
|
||||||
|
deleteWallet.show(1);
|
||||||
|
// The settings gear: push the current view, then show Settings.
|
||||||
|
helpers.pushCurrentView();
|
||||||
|
helpers.showView("settings");
|
||||||
|
|
||||||
|
expect(state.viewStack).toEqual(["main", "settings"]);
|
||||||
|
helpers.goBack();
|
||||||
|
expect(state.currentView).toBe("main");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("goes past the lost-password screen to the screen under Settings", async () => {
|
||||||
|
const { helpers, deleteWallet, state } = load();
|
||||||
|
await openLostPassword(deleteWallet, 1);
|
||||||
|
// The settings gear: push the current view, then show Settings.
|
||||||
|
helpers.pushCurrentView();
|
||||||
|
helpers.showView("settings");
|
||||||
|
|
||||||
|
expect(state.viewStack).toEqual(["main", "settings"]);
|
||||||
|
helpers.goBack();
|
||||||
|
expect(state.currentView).toBe("main");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The lost-password screen's own Back is "Back returns to the delete
|
||||||
|
// screen with its wallet still chosen", above.
|
||||||
|
test("the delete screen's own Back leaves the rest of the stack alone", async () => {
|
||||||
|
const { deleteWallet, state } = load();
|
||||||
|
deleteWallet.show(1);
|
||||||
|
|
||||||
|
await click("btn-delete-wallet-back");
|
||||||
|
|
||||||
|
expect(state.currentView).toBe("settings");
|
||||||
|
expect(state.viewStack).toEqual(["main"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ describe("the flash line the message is shown in", () => {
|
|||||||
// "a rejected dust threshold shifts no layout (#233)" and "an over-long
|
// "a rejected dust threshold shifts no layout (#233)" and "an over-long
|
||||||
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by
|
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by
|
||||||
// make test-e2e. They are not in make check because REPO_POLICIES.md
|
// make test-e2e. They are not in make check because REPO_POLICIES.md
|
||||||
// caps make test at 20 seconds and a browser suite does not fit.
|
// caps make test at 60 seconds and a browser suite does not fit.
|
||||||
test("reserves its height in the markup", () => {
|
test("reserves its height in the markup", () => {
|
||||||
const flashLine = POPUP_HTML.match(
|
const flashLine = POPUP_HTML.match(
|
||||||
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
|
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// node tests/e2e/firefox/run.js [dist/firefox]
|
// node tests/e2e/firefox/run.js [dist/firefox]
|
||||||
//
|
//
|
||||||
// Deliberately not part of script/check, and deliberately not named
|
// Deliberately not part of script/check, and deliberately not named
|
||||||
// *.test.js: REPO_POLICIES.md caps make test at 20 seconds and a browser
|
// *.test.js: REPO_POLICIES.md caps make test at 60 seconds and a browser
|
||||||
// suite does not fit.
|
// suite does not fit.
|
||||||
//
|
//
|
||||||
// This shares no driver layer with the Chrome suite in tests/e2e/, and the
|
// This shares no driver layer with the Chrome suite in tests/e2e/, and the
|
||||||
@@ -854,37 +854,6 @@ step(
|
|||||||
|
|
||||||
// ------------------------------------------------------------- runner
|
// ------------------------------------------------------------- runner
|
||||||
|
|
||||||
// Uncaught extension errors that are known, tracked and deliberately
|
|
||||||
// tolerated, in the same spirit as ALLOWED_ERRORS in tests/e2e/harness.js:
|
|
||||||
// every entry names the issue that will delete it, and every occurrence is
|
|
||||||
// still printed, so tolerating one is visible in the log rather than silent.
|
|
||||||
// This is the only concession in an otherwise zero-tolerance policy.
|
|
||||||
const ALLOWED_ERRORS = [
|
|
||||||
{
|
|
||||||
// The site-connection buttons in src/popup/views/approval.js send
|
|
||||||
// their decision and call window.close() on the next line. Firefox's
|
|
||||||
// BaseContext.wrapPromise reports, through Cu.reportError, any
|
|
||||||
// extension-API promise that settles after its context unloaded —
|
|
||||||
// whether or not the caller attached a handler, so notify()'s catch
|
|
||||||
// cannot suppress it.
|
|
||||||
//
|
|
||||||
// Pre-existing, and not introduced by the promise shim: the send was
|
|
||||||
// already unawaited, and this suite is merely the first thing to
|
|
||||||
// drive that window on Firefox. It is the same teardown ordering as
|
|
||||||
// the issue below, whose fix — making the outcome independent of when
|
|
||||||
// the popup closes — removes this entry with it.
|
|
||||||
pattern: /Promise (?:resolved|rejected) after context unloaded/,
|
|
||||||
source: /\/src\/popup\/index\.js$/,
|
|
||||||
issue: "https://git.eeqj.de/sneak/AutistMask/issues/275",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function allowedFor(e) {
|
|
||||||
return ALLOWED_ERRORS.find(
|
|
||||||
(a) => a.pattern.test(e.msg) && a.source.test(e.src),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatError(e) {
|
function formatError(e) {
|
||||||
return (
|
return (
|
||||||
e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")"
|
e.msg + " (" + e.src + ":" + e.line + (e.cat ? ", " + e.cat : "") + ")"
|
||||||
@@ -1001,20 +970,6 @@ async function main() {
|
|||||||
installFailure = null;
|
installFailure = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tolerated errors are set aside, never dropped: each one is
|
|
||||||
// printed with the issue that keeps it on the list, so the
|
|
||||||
// concession stays in the run output.
|
|
||||||
const tolerated = found.filter((e) => allowedFor(e));
|
|
||||||
found = found.filter((e) => !allowedFor(e));
|
|
||||||
for (const e of tolerated) {
|
|
||||||
console.log(
|
|
||||||
"# tolerated (" +
|
|
||||||
allowedFor(e).issue +
|
|
||||||
"): " +
|
|
||||||
formatError(e),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any uncaught error from an extension source fails the step
|
// Any uncaught error from an extension source fails the step
|
||||||
// that provoked it, whether or not its assertions passed.
|
// that provoked it, whether or not its assertions passed.
|
||||||
if (!failure && found.length > 0) {
|
if (!failure && found.length > 0) {
|
||||||
@@ -1039,13 +994,7 @@ async function main() {
|
|||||||
// blamed on any one step, but they are still reported and they
|
// blamed on any one step, but they are still reported and they
|
||||||
// still fail the run.
|
// still fail the run.
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
const trailingAll = await errors.take();
|
const trailing = await errors.take();
|
||||||
for (const e of trailingAll.filter((x) => allowedFor(x))) {
|
|
||||||
console.log(
|
|
||||||
"# tolerated (" + allowedFor(e).issue + "): " + formatError(e),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const trailing = trailingAll.filter((e) => !allowedFor(e));
|
|
||||||
console.log(
|
console.log(
|
||||||
"# " +
|
"# " +
|
||||||
(steps.length - failed) +
|
(steps.length - failed) +
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// This runs inside the pinned Playwright container; see script/test-e2e.
|
// This runs inside the pinned Playwright container; see script/test-e2e.
|
||||||
// It is deliberately NOT part of make check — REPO_POLICIES.md caps
|
// It is deliberately NOT part of make check — REPO_POLICIES.md caps
|
||||||
// make test at 20 seconds and a browser suite does not fit.
|
// make test at 60 seconds and a browser suite does not fit.
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
|||||||
+25
-15
@@ -418,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, holdTransactionCount or holdBlockscout) is read
|
||||||
// on the same options object the route was registered with — the same
|
// here rather than captured, so a test flips it on the same options object the
|
||||||
// pattern as seedTokenTransfer. This is the only way to observe the
|
// route was registered with — the same pattern as seedTokenTransfer. This is
|
||||||
// confirmation screen while its estimate is genuinely in flight; sampling
|
// the only way to observe a screen while its request is genuinely in flight;
|
||||||
// the screen and hoping to win a race against the network would assert
|
// sampling the screen and hoping to win a race against the network would
|
||||||
// nothing on a slow machine.
|
// assert nothing on 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);
|
||||||
@@ -562,7 +558,10 @@ 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);
|
||||||
|
}
|
||||||
|
if (batch.some((req) => req.method === "eth_getTransactionCount")) {
|
||||||
|
await awaitRelease(opts, "holdTransactionCount", report);
|
||||||
}
|
}
|
||||||
|
|
||||||
const replies = batch.map((req) => rpcReply(req, opts, report));
|
const replies = batch.map((req) => rpcReply(req, opts, report));
|
||||||
@@ -618,6 +617,12 @@ 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.holdTransactionCount] hold every batch containing an
|
||||||
|
* eth_getTransactionCount 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
|
||||||
@@ -693,7 +698,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:
|
||||||
|
|||||||
+218
-19
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// A plain runner rather than jest on purpose: jest's default testMatch
|
// A plain runner rather than jest on purpose: jest's default testMatch
|
||||||
// would pull these files into script/test, and browser tests do not fit
|
// would pull these files into script/test, and browser tests do not fit
|
||||||
// inside the 20-second cap REPO_POLICIES.md puts on make test. Nothing
|
// inside the 60-second cap REPO_POLICIES.md puts on make test. Nothing
|
||||||
// here is named *.test.js for the same reason.
|
// here is named *.test.js for the same reason.
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -249,8 +249,23 @@ test("the exact confirmation phrase erases the record and reloads into Welcome (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("wallet creation through the UI reaches the main view", async (env) => {
|
// The address scan that follows creating the wallet is held, so the popup
|
||||||
|
// reloads while it is in flight. As for the reloads mid-refresh further down,
|
||||||
|
// the assertion for that is the harness's own: a console.error from the page
|
||||||
|
// being reloaded fails this test (#475).
|
||||||
|
test("wallet creation through the UI reaches the main view, and reloading mid-scan reports no failure (#475)", async (env) => {
|
||||||
|
env.routeOpts.holdTransactionCount = true;
|
||||||
|
try {
|
||||||
|
const scanning = env.page.waitForRequest((r) =>
|
||||||
|
(r.postData() || "").includes("eth_getTransactionCount"),
|
||||||
|
);
|
||||||
env.phrase = await createWallet(env.page);
|
env.phrase = await createWallet(env.page);
|
||||||
|
await scanning;
|
||||||
|
await env.page.reload();
|
||||||
|
} finally {
|
||||||
|
env.routeOpts.holdTransactionCount = false;
|
||||||
|
}
|
||||||
|
await visible(env.page, "#view-main");
|
||||||
assert(
|
assert(
|
||||||
env.phrase.split(/\s+/).length >= 12,
|
env.phrase.split(/\s+/).length >= 12,
|
||||||
"wallet creation did not yield a recovery phrase",
|
"wallet creation did not yield a recovery phrase",
|
||||||
@@ -696,6 +711,85 @@ test("the token contract row links to the explorer's token page (#151)", async (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------- 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. The reload during the address
|
||||||
|
// scan that follows creating a wallet is in the wallet creation test (#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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The address screen's transaction list, held the same way. The popup reopens
|
||||||
|
// on the address screen, which asks for its list as it is drawn, so once the
|
||||||
|
// screen is up its request is in flight (#475).
|
||||||
|
test("reloading the popup on the address screen reports no failure (#475)", async (env) => {
|
||||||
|
await openAddressDetail(env.page);
|
||||||
|
await waitForPersisted(
|
||||||
|
env.page,
|
||||||
|
"currentView",
|
||||||
|
"address",
|
||||||
|
"before reloading the popup",
|
||||||
|
);
|
||||||
|
env.routeOpts.holdBlockscout = true;
|
||||||
|
try {
|
||||||
|
await env.page.reload();
|
||||||
|
await visible(env.page, "#view-address");
|
||||||
|
await env.page.reload();
|
||||||
|
} finally {
|
||||||
|
env.routeOpts.holdBlockscout = false;
|
||||||
|
}
|
||||||
|
await visible(env.page, "#view-address");
|
||||||
|
// Home again, where the tests below expect to start.
|
||||||
|
await goHome(env.page);
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------- 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.
|
||||||
@@ -715,7 +809,7 @@ async function secretScreenState(page, view) {
|
|||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
(v) => ({
|
(v) => ({
|
||||||
value: document.getElementById(v + "-value").textContent,
|
value: document.getElementById(v + "-value").textContent,
|
||||||
error: document.getElementById(v + "-flash").textContent,
|
error: document.getElementById(v + "-password-error").textContent,
|
||||||
html: document.getElementById("view-" + v).innerHTML,
|
html: document.getElementById("view-" + v).innerHTML,
|
||||||
resultHidden: document
|
resultHidden: document
|
||||||
.getElementById(v + "-result")
|
.getElementById(v + "-result")
|
||||||
@@ -812,7 +906,8 @@ test("a wrong password reveals nothing (#161)", async (env) => {
|
|||||||
await env.page.click("#btn-show-phrase-reveal");
|
await env.page.click("#btn-show-phrase-reveal");
|
||||||
await env.page.waitForFunction(
|
await env.page.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
document.getElementById("show-phrase-flash").textContent.length > 0,
|
document.getElementById("show-phrase-password-error").textContent
|
||||||
|
.length > 0,
|
||||||
null,
|
null,
|
||||||
{ timeout: 60000 },
|
{ timeout: 60000 },
|
||||||
);
|
);
|
||||||
@@ -981,13 +1076,13 @@ async function revealPrivkey(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Leave the export screen, or the Settings screen the gear left it for, for
|
// 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
|
// Home. Leaving takes the export screen off the Back stack, so from Settings
|
||||||
// way home passes through it, already emptied
|
// Back goes to the address screen it was opened from
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
|
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
|
||||||
async function leavePrivkeyScreen(page) {
|
async function leavePrivkeyScreen(page) {
|
||||||
if (await page.isVisible("#view-settings")) {
|
if (await page.isVisible("#view-settings")) {
|
||||||
await page.click("#btn-settings-back");
|
await page.click("#btn-settings-back");
|
||||||
await visible(page, "#view-export-privkey");
|
await visible(page, "#view-address");
|
||||||
}
|
}
|
||||||
if (await page.isVisible("#view-export-privkey")) {
|
if (await page.isVisible("#view-export-privkey")) {
|
||||||
await page.click("#btn-export-privkey-back");
|
await page.click("#btn-export-privkey-back");
|
||||||
@@ -1048,10 +1143,8 @@ test("leaving by the settings gear wipes the private key (#253)", async (env) =>
|
|||||||
test("leaving while the decrypt is in flight reveals no private key (#253)", async (env) => {
|
test("leaving while the decrypt is in flight reveals no private key (#253)", async (env) => {
|
||||||
try {
|
try {
|
||||||
await leavePrivkeyScreen(env.page);
|
await leavePrivkeyScreen(env.page);
|
||||||
// The export screen cannot yet be opened twice in one popup session
|
// The second open in this popup session
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/460), so this second
|
// (https://git.eeqj.de/sneak/AutistMask/issues/460).
|
||||||
// open gets a fresh one.
|
|
||||||
await reopenPopup(env, "main");
|
|
||||||
await openPrivkeyScreen(env.page);
|
await openPrivkeyScreen(env.page);
|
||||||
await env.page.fill("#export-privkey-password", PASSWORD);
|
await env.page.fill("#export-privkey-password", PASSWORD);
|
||||||
const inFlight = await env.page.evaluate(() => {
|
const inFlight = await env.page.evaluate(() => {
|
||||||
@@ -1901,13 +1994,14 @@ test("an over-long flash message keeps to one line (#252)", async (env) => {
|
|||||||
|
|
||||||
// Every screen that asks for a password reserves room for one line of error.
|
// Every screen that asks for a password reserves room for one line of error.
|
||||||
// The two on the dApp approval screens also have a border and padding, which
|
// The two on the dApp approval screens also have a border and padding, which
|
||||||
// that reserved height has to cover too.
|
// that reserved height has to cover too. The add wallet screen's line sits
|
||||||
|
// beside its button rather than above it, and has its own test below.
|
||||||
const PASSWORD_ERROR_CONTAINERS = [
|
const PASSWORD_ERROR_CONTAINERS = [
|
||||||
"approve-tx-error",
|
"approve-tx-error",
|
||||||
"approve-sign-error",
|
"approve-sign-error",
|
||||||
"export-privkey-flash",
|
"export-privkey-password-error",
|
||||||
"show-phrase-flash",
|
"show-phrase-password-error",
|
||||||
"delete-wallet-flash",
|
"delete-wallet-password-error",
|
||||||
"confirm-tx-password-error",
|
"confirm-tx-password-error",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1972,6 +2066,107 @@ test("a password error moves nothing on any screen (#297)", async (env) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------ add wallet Import button (#493)
|
||||||
|
|
||||||
|
// At 360x600 the add wallet screen's Import button already starts near the
|
||||||
|
// bottom of the popup, so its password error line sits beside the button
|
||||||
|
// rather than above it. Measures the button and the line empty and again
|
||||||
|
// filled with the longest error addWallet.js puts there. Runs in the page.
|
||||||
|
function measureImportButton() {
|
||||||
|
const button = document.getElementById("btn-add-wallet-confirm");
|
||||||
|
const line = document.getElementById("add-wallet-password-error");
|
||||||
|
const measure = () => {
|
||||||
|
const b = button.getBoundingClientRect();
|
||||||
|
const l = line.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
buttonTop: b.top + window.scrollY,
|
||||||
|
buttonBottom: b.bottom + window.scrollY,
|
||||||
|
lineTop: l.top + window.scrollY,
|
||||||
|
lineBottom: l.bottom + window.scrollY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const empty = measure();
|
||||||
|
line.textContent = "Password must be at least 12 characters.";
|
||||||
|
line.style.visibility = "visible";
|
||||||
|
const filled = measure();
|
||||||
|
line.textContent = "";
|
||||||
|
line.style.visibility = "hidden";
|
||||||
|
return { empty, filled };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the add wallet password error leaves Import where it was (#493)", async (env) => {
|
||||||
|
const page = await openPopup(env.ctx, env.popupUrl);
|
||||||
|
try {
|
||||||
|
await page.setViewportSize(POPUP_VIEWPORT);
|
||||||
|
// Brought up by toggling classes, as in the test above. The note
|
||||||
|
// addWallet.js shows once a wallet exists is the only part of the
|
||||||
|
// screen that differs between the first wallet and a later one.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const screen = document.getElementById("view-add-wallet");
|
||||||
|
for (const view of document.querySelectorAll(".view")) {
|
||||||
|
view.classList.toggle("hidden", view !== screen);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const walletExists of [false, true]) {
|
||||||
|
await page.evaluate(
|
||||||
|
(shown) =>
|
||||||
|
document
|
||||||
|
.getElementById("add-wallet-separate-password-note")
|
||||||
|
.classList.toggle("hidden", !shown),
|
||||||
|
walletExists,
|
||||||
|
);
|
||||||
|
for (const tab of ["tab-mnemonic", "tab-privkey", "tab-xprv"]) {
|
||||||
|
await page.click("#" + tab);
|
||||||
|
const { empty, filled } =
|
||||||
|
await page.evaluate(measureImportButton);
|
||||||
|
const where =
|
||||||
|
"#" +
|
||||||
|
tab +
|
||||||
|
(walletExists
|
||||||
|
? " with a wallet already added"
|
||||||
|
: " for the first wallet");
|
||||||
|
// Printed pass or fail, as the dust threshold test does.
|
||||||
|
console.log(
|
||||||
|
"# add wallet Import top, " +
|
||||||
|
where +
|
||||||
|
": " +
|
||||||
|
empty.buttonTop +
|
||||||
|
"px",
|
||||||
|
);
|
||||||
|
for (const m of [empty, filled]) {
|
||||||
|
assert(
|
||||||
|
m.lineTop >= m.buttonTop &&
|
||||||
|
m.lineBottom <= m.buttonBottom,
|
||||||
|
"the error line on " +
|
||||||
|
where +
|
||||||
|
" does not fit beside Import, so it adds height: " +
|
||||||
|
JSON.stringify(m),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
filled.buttonTop === empty.buttonTop,
|
||||||
|
"Import moved " +
|
||||||
|
(filled.buttonTop - empty.buttonTop) +
|
||||||
|
"px when the error appeared on " +
|
||||||
|
where,
|
||||||
|
);
|
||||||
|
if (!walletExists) {
|
||||||
|
assert(
|
||||||
|
empty.buttonTop < POPUP_VIEWPORT.height,
|
||||||
|
"Import starts at " +
|
||||||
|
empty.buttonTop +
|
||||||
|
"px on " +
|
||||||
|
where +
|
||||||
|
", below the fold",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --------------------------------------------- confirmation screen (#238)
|
// --------------------------------------------- confirmation screen (#238)
|
||||||
//
|
//
|
||||||
// The screen that decides what gets signed. The arithmetic underneath it
|
// The screen that decides what gets signed. The arithmetic underneath it
|
||||||
@@ -2107,10 +2302,7 @@ 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.
|
||||||
// itself, and reloading aborts whatever fetch the home screen has open at
|
|
||||||
// that instant, which the extension reports through log.errorf and the
|
|
||||||
// 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
|
||||||
@@ -4470,6 +4662,13 @@ async function main() {
|
|||||||
ethBalanceWei: null,
|
ethBalanceWei: null,
|
||||||
failGasEstimate: false,
|
failGasEstimate: false,
|
||||||
holdGasEstimate: false,
|
holdGasEstimate: false,
|
||||||
|
// Whether a JSON-RPC batch asking for a transaction count is held
|
||||||
|
// unanswered: the address scan after creating a wallet (#475).
|
||||||
|
holdTransactionCount: 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).
|
||||||
|
|||||||
@@ -58,7 +58,20 @@ function makeElement(id, withParent) {
|
|||||||
remove: () => {},
|
remove: () => {},
|
||||||
querySelectorAll: () => [],
|
querySelectorAll: () => [],
|
||||||
};
|
};
|
||||||
el.parentElement = withParent ? makeElement(id + "-parent", false) : null;
|
el.parentElement = null;
|
||||||
|
if (withParent) {
|
||||||
|
// As in a browser, replacing the parent's contents takes this
|
||||||
|
// element out of the document: getElementById no longer finds it.
|
||||||
|
el.parentElement = makeElement(id + "-parent", false);
|
||||||
|
let html = "";
|
||||||
|
Object.defineProperty(el.parentElement, "innerHTML", {
|
||||||
|
get: () => html,
|
||||||
|
set: (value) => {
|
||||||
|
html = value;
|
||||||
|
el.removed = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +83,8 @@ function makeDocument() {
|
|||||||
// is the state a non-debug, non-testnet popup is in.
|
// is the state a non-debug, non-testnet popup is in.
|
||||||
if (id === "debug-banner") return null;
|
if (id === "debug-banner") return null;
|
||||||
if (!els.has(id)) els.set(id, makeElement(id, true));
|
if (!els.has(id)) els.set(id, makeElement(id, true));
|
||||||
return els.get(id);
|
const el = els.get(id);
|
||||||
|
return el.removed ? null : el;
|
||||||
},
|
},
|
||||||
createElement: () => makeElement("created", false),
|
createElement: () => makeElement("created", false),
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
@@ -193,7 +207,7 @@ describe("a decrypt still running when the screen is left", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Same hole on the failure path: a wrong-password error written after
|
// Same hole on the failure path: a wrong-password error written after
|
||||||
// the wipe would restore the flash line on a screen the user has left.
|
// the wipe would restore the error line on a screen the user has left.
|
||||||
test("never writes the failure message either", async () => {
|
test("never writes the failure message either", async () => {
|
||||||
const { helpers, vault, exportPrivkey } = load();
|
const { helpers, vault, exportPrivkey } = load();
|
||||||
exportPrivkey.show(0, 0);
|
exportPrivkey.show(0, 0);
|
||||||
@@ -203,8 +217,10 @@ describe("a decrypt still running when the screen is left", () => {
|
|||||||
reveal.reject(new Error("decryption failed"));
|
reveal.reject(new Error("decryption failed"));
|
||||||
await reveal.pending;
|
await reveal.pending;
|
||||||
|
|
||||||
expect(node("export-privkey-flash").textContent).toBe("");
|
expect(node("export-privkey-password-error").textContent).toBe("");
|
||||||
expect(node("export-privkey-flash").style.visibility).toBe("hidden");
|
expect(node("export-privkey-password-error").style.visibility).toBe(
|
||||||
|
"hidden",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,7 +262,7 @@ describe("a reveal that is not interrupted", () => {
|
|||||||
await reveal.pending;
|
await reveal.pending;
|
||||||
|
|
||||||
expect(node("export-privkey-value").textContent).toBe("");
|
expect(node("export-privkey-value").textContent).toBe("");
|
||||||
expect(node("export-privkey-flash").textContent).toBe(
|
expect(node("export-privkey-password-error").textContent).toBe(
|
||||||
"That password is incorrect. Please try again.",
|
"That password is incorrect. Please try again.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -300,6 +316,58 @@ describe("leaving the screen after the key is on it", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("opening the screen again in the same popup session", () => {
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/460: show() found the
|
||||||
|
// address line through an element inside it, which its own rendering
|
||||||
|
// deleted, so the second open threw before it navigated.
|
||||||
|
test("shows it for the address chosen the second time", async () => {
|
||||||
|
const { state, exportPrivkey } = load();
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
await click("btn-export-privkey-back");
|
||||||
|
expect(state.currentView).toBe("address");
|
||||||
|
|
||||||
|
exportPrivkey.show(0, 1);
|
||||||
|
|
||||||
|
expect(state.currentView).toBe(VIEW);
|
||||||
|
expect(node("export-privkey-title").textContent).toBe(
|
||||||
|
"Wallet 1 — Address 2",
|
||||||
|
);
|
||||||
|
expect(node("export-privkey-address").innerHTML).toContain(
|
||||||
|
"0x" + "22".repeat(20),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Back from Settings after leaving by the settings gear", () => {
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/461: leaving drops the
|
||||||
|
// address selection, so Back onto this screen showed a password prompt
|
||||||
|
// that could only answer "No address is selected."
|
||||||
|
test("goes to the address screen it was opened from", () => {
|
||||||
|
const { helpers, state, exportPrivkey } = load();
|
||||||
|
state.viewStack = ["main"];
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
// The settings gear: push the current view, then show Settings.
|
||||||
|
helpers.pushCurrentView();
|
||||||
|
helpers.showView("settings");
|
||||||
|
|
||||||
|
helpers.goBack();
|
||||||
|
|
||||||
|
expect(state.currentView).toBe("address");
|
||||||
|
expect(state.viewStack).toEqual(["main"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("its own Back button leaves the rest of the stack alone", async () => {
|
||||||
|
const { state, exportPrivkey } = load();
|
||||||
|
state.viewStack = ["main"];
|
||||||
|
exportPrivkey.show(0, 0);
|
||||||
|
|
||||||
|
await click("btn-export-privkey-back");
|
||||||
|
|
||||||
|
expect(state.currentView).toBe("address");
|
||||||
|
expect(state.viewStack).toEqual(["main"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("views the popup may reopen onto", () => {
|
describe("views the popup may reopen onto", () => {
|
||||||
// Restoring onto this screen would put a private key on display with no
|
// Restoring onto this screen would put a private key on display with no
|
||||||
// password prompt in front of it, on a popup reopened by accident.
|
// password prompt in front of it, on a popup reopened by accident.
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ describe.each([
|
|||||||
|
|
||||||
require("../src/popup/views/" + view).init({
|
require("../src/popup/views/" + view).init({
|
||||||
doRefreshAndRender: () => {},
|
doRefreshAndRender: () => {},
|
||||||
|
pageClosed: new AbortController().signal,
|
||||||
});
|
});
|
||||||
element(field).value = ADDRESS;
|
element(field).value = ADDRESS;
|
||||||
await element(button).listeners.click();
|
await element(button).listeners.click();
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// The toolbar icons in icons/ are drawn by script/lib/icons.js (`make icons`).
|
||||||
|
// Each committed file must hold exactly the image it draws, the same IHDR and
|
||||||
|
// every pixel, so the drawing and the files cannot drift apart. The compressed
|
||||||
|
// bytes are not compared: the committed files were compressed by stock zlib,
|
||||||
|
// and node's bundled zlib compresses the same pixels differently.
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const { icons } = require("../manifest/chrome.json");
|
||||||
|
const { drawIcon, decodePng } = require("../script/lib/icons");
|
||||||
|
|
||||||
|
describe("toolbar icons", () => {
|
||||||
|
test.each(Object.entries(icons))(
|
||||||
|
"the %spx icon %s holds the image script/lib/icons.js draws",
|
||||||
|
(size, file) => {
|
||||||
|
const side = Number(size);
|
||||||
|
const committed = decodePng(
|
||||||
|
fs.readFileSync(path.join(__dirname, "..", file)),
|
||||||
|
);
|
||||||
|
const drawn = decodePng(drawIcon(side));
|
||||||
|
|
||||||
|
expect(committed.header).toEqual(drawn.header);
|
||||||
|
// Each row is its filter type byte, then 4 bytes per pixel.
|
||||||
|
expect(drawn.rows.length).toBe(side * (side * 4 + 1));
|
||||||
|
expect(committed.rows.length).toBe(drawn.rows.length);
|
||||||
|
// The offset of the first byte that differs, not a diff of all.
|
||||||
|
expect(
|
||||||
|
committed.rows.findIndex((byte, i) => byte !== drawn.rows[i]),
|
||||||
|
).toBe(-1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// Every screen that asks for a password shows a password error the same way:
|
||||||
|
// through showError() and hideError() in src/popup/views/helpers.js, in a
|
||||||
|
// fixed-height error line below the password field, and never in the flash
|
||||||
|
// line at the top of the popup
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/493). These boot the real popup
|
||||||
|
// over src/popup/index.html, so each error line has to exist in the markup,
|
||||||
|
// and check that the error appears in it and clears again.
|
||||||
|
|
||||||
|
jest.mock("../src/shared/vault", () => ({
|
||||||
|
decryptWithPassword: jest.fn(),
|
||||||
|
encryptWithPassword: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
|
||||||
|
const PASSWORD = "correct horse battery staple";
|
||||||
|
const WRONG_PASSWORD = "That password is incorrect. Please try again.";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The error line as the user sees it.
|
||||||
|
function errorLine(page, id) {
|
||||||
|
return {
|
||||||
|
inMarkup: page.document.authoredIds.has(id),
|
||||||
|
text: page.text(id),
|
||||||
|
visibility: page.node(id).style.visibility,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shown(text) {
|
||||||
|
return { inMarkup: true, text, visibility: "visible" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleared = { inMarkup: true, text: "", visibility: "hidden" };
|
||||||
|
|
||||||
|
describe("the add wallet screen", () => {
|
||||||
|
const ERROR = "add-wallet-password-error";
|
||||||
|
|
||||||
|
// First run: Welcome, "Add wallet", then the die for a valid phrase.
|
||||||
|
async function openAddWallet() {
|
||||||
|
const page = await bootPopup(undefined);
|
||||||
|
await page.click("btn-welcome-add");
|
||||||
|
await page.click("btn-generate-phrase");
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPasswords(page, password, confirm) {
|
||||||
|
page.node("add-wallet-password").value = password;
|
||||||
|
page.node("add-wallet-password-confirm").value = confirm;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shows a password problem below the password fields, and clears it on the next press", async () => {
|
||||||
|
const page = await openAddWallet();
|
||||||
|
setPasswords(page, "short", "short");
|
||||||
|
await page.click("btn-add-wallet-confirm");
|
||||||
|
expect(errorLine(page, ERROR)).toEqual(
|
||||||
|
shown("Password must be at least 12 characters."),
|
||||||
|
);
|
||||||
|
expect(page.text("flash-msg")).toBe("");
|
||||||
|
|
||||||
|
// The password is fixed and the phrase emptied: the password error
|
||||||
|
// goes, and the phrase problem is still reported in the flash line.
|
||||||
|
setPasswords(page, PASSWORD, PASSWORD);
|
||||||
|
page.node("wallet-mnemonic").value = "";
|
||||||
|
await page.click("btn-add-wallet-confirm");
|
||||||
|
expect(errorLine(page, ERROR)).toEqual(cleared);
|
||||||
|
expect(page.text("flash-msg")).toBe(
|
||||||
|
"Enter a recovery phrase, or press the die.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Leaving clears the flash line and stops its timer, which would
|
||||||
|
// otherwise fire after this page is gone.
|
||||||
|
await page.click("btn-add-wallet-back");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears the error when the screen is shown again", async () => {
|
||||||
|
const page = await openAddWallet();
|
||||||
|
setPasswords(page, PASSWORD, PASSWORD + " typo");
|
||||||
|
await page.click("btn-add-wallet-confirm");
|
||||||
|
expect(errorLine(page, ERROR)).toEqual(
|
||||||
|
shown("Passwords do not match."),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.click("btn-add-wallet-back");
|
||||||
|
await page.click("btn-welcome-add");
|
||||||
|
expect(errorLine(page, ERROR)).toEqual(cleared);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
{
|
||||||
|
screen: "the private key export screen",
|
||||||
|
open: () => require("../src/popup/views/exportPrivkey").show(0, 0),
|
||||||
|
field: "export-privkey-password",
|
||||||
|
button: "btn-export-privkey-confirm",
|
||||||
|
error: "export-privkey-password-error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
screen: "the recovery phrase screen",
|
||||||
|
open: () => require("../src/popup/views/showPhrase").show(0),
|
||||||
|
field: "show-phrase-password",
|
||||||
|
button: "btn-show-phrase-reveal",
|
||||||
|
error: "show-phrase-password-error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
screen: "the delete wallet screen",
|
||||||
|
open: () => require("../src/popup/views/deleteWallet").show(0),
|
||||||
|
field: "delete-wallet-password",
|
||||||
|
button: "btn-delete-wallet-confirm",
|
||||||
|
error: "delete-wallet-password-error",
|
||||||
|
},
|
||||||
|
])("$screen", ({ open, field, button, error }) => {
|
||||||
|
// Opens the screen and enters a password the vault rejects.
|
||||||
|
async function failedAttempt() {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
open();
|
||||||
|
const { decryptWithPassword } = require("../src/shared/vault");
|
||||||
|
decryptWithPassword.mockRejectedValue(new Error("wrong password"));
|
||||||
|
page.node(field).value = "not the password";
|
||||||
|
await page.click(button);
|
||||||
|
return { page, decryptWithPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shows a wrong password below the password field", async () => {
|
||||||
|
const { page } = await failedAttempt();
|
||||||
|
expect(errorLine(page, error)).toEqual(shown(WRONG_PASSWORD));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears the error while the next password is checked", async () => {
|
||||||
|
const { page, decryptWithPassword } = await failedAttempt();
|
||||||
|
let rejectDecrypt;
|
||||||
|
decryptWithPassword.mockReturnValue(
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
rejectDecrypt = reject;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
page.node(field).value = "another guess";
|
||||||
|
const pressed = page.click(button);
|
||||||
|
await page.settle();
|
||||||
|
expect(errorLine(page, error)).toEqual(cleared);
|
||||||
|
|
||||||
|
rejectDecrypt(new Error("wrong password"));
|
||||||
|
await pressed;
|
||||||
|
expect(errorLine(page, error)).toEqual(shown(WRONG_PASSWORD));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears the error when the screen is shown again", async () => {
|
||||||
|
const { page } = await failedAttempt();
|
||||||
|
open();
|
||||||
|
expect(errorLine(page, error)).toEqual(cleared);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,22 +49,37 @@
|
|||||||
// every path a stored record takes, and the difference is the whole of what
|
// every path a stored record takes, and the difference is the whole of what
|
||||||
// this file does not cover:
|
// this file does not cover:
|
||||||
//
|
//
|
||||||
// - Only the values in the table, in the SLOT arrangement below: four value
|
// - Only the values in the table, in the SLOT arrangement below. On the
|
||||||
// combinations per view, not the product of twelve fields. A dereference
|
// restore path the twelve fields the router does not read are corrupted
|
||||||
// reached only under a pairing no slot produces is not driven at all.
|
// together, every field on the same slot, so a view gets four value
|
||||||
// - Only what a stored record reaches by ITSELF. A view only forward
|
// combinations of them, not their product. A branch entered only when one
|
||||||
// navigation opens, and anything behind a click, is not driven.
|
// of them is truthy and another falsy is reached only where the falsy
|
||||||
// - Nothing about the paths a HEALTHY profile takes, which is most of the
|
// slot happens to pair a field that cannot be falsy with one that is.
|
||||||
// popup. This file is a floor under one defect class, not a proof about
|
// Each field the router reads is corrupted alone, over an otherwise
|
||||||
// the renderers.
|
// well-formed record.
|
||||||
|
// - Only what a stored record reaches by ITSELF, as the boot renders it. A
|
||||||
|
// view only forward navigation opens, anything behind a click, and
|
||||||
|
// anything behind a timer (bootPopup() records every interval, and this
|
||||||
|
// file never runs one) is not driven.
|
||||||
|
// - Of the paths a HEALTHY profile takes, only its boot onto each
|
||||||
|
// restorable view ("the base profile the sweep corrupts" below). The rest
|
||||||
|
// of the popup is not covered: this file is a floor under one defect
|
||||||
|
// class, not a proof about the renderers.
|
||||||
//
|
//
|
||||||
// Within that boundary it is unconditional: if one of these boots leaves the
|
// Within that boundary it is unconditional: if a boot that corrupts a field
|
||||||
// popup unhealthy or off the view it stored, this file goes red — including
|
// leaves the popup unhealthy, this file goes red — including when it takes
|
||||||
// when it takes two corrupted fields at once, because the verdict is the
|
// two corrupted fields at once, because the verdict is the combined boot
|
||||||
// combined boot itself and the per-field re-boot below can only decorate the
|
// itself and the per-field re-boot below can only decorate the message. That
|
||||||
// message. That last part is the one thing an earlier version got wrong: it
|
// last part is the one thing an earlier version got wrong: it asserted on the
|
||||||
// asserted on the per-field list, so an observed dead popup that no single
|
// per-field list, so an observed dead popup that no single field reproduced
|
||||||
// field reproduced was reported green.
|
// was reported green.
|
||||||
|
//
|
||||||
|
// Where the popup lands is held for some of those boots and not others. The
|
||||||
|
// combined boot must land on the view it stored, and each `hostileRestore`
|
||||||
|
// value must land on its view, or fall back to Home, as its entry declares.
|
||||||
|
// The boots in "a hostile routing value restoring onto" are held to health
|
||||||
|
// alone, because a value in a field the router reads legitimately changes
|
||||||
|
// which view renders; so are the boots onto Home, which store no view.
|
||||||
//
|
//
|
||||||
// Booting every field separately at every value would be several hundred boots
|
// Booting every field separately at every value would be several hundred boots
|
||||||
// and most of the suite's budget; this is forty-four. Widening it further is
|
// and most of the suite's budget; this is forty-four. Widening it further is
|
||||||
@@ -128,7 +143,11 @@ const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
|
|||||||
// list short and pointed. `floorOnly` is extra values checked against the
|
// list short and pointed. `floorOnly` is extra values checked against the
|
||||||
// floor alone, which is pure and free. `hostileRestore` is extra values driven
|
// floor alone, which is pure and free. `hostileRestore` is extra values driven
|
||||||
// through the restore path only, for a value that means nothing until a
|
// through the restore path only, for a value that means nothing until a
|
||||||
// particular branch's gate has let it past.
|
// particular branch's gate has let it past. Each of its entries names the
|
||||||
|
// `views` it is driven onto and declares whether the boot lands on them
|
||||||
|
// (`restored: true`) or falls back to Home (`restored: false`), so a value
|
||||||
|
// written for one renderer cannot stop reaching it unnoticed. A value driven
|
||||||
|
// onto every restorable view is written with everyRestorableView() below.
|
||||||
//
|
//
|
||||||
// `falsy` is the other POLARITY of a swept field, driven for the same reason.
|
// `falsy` is the other POLARITY of a swept field, driven for the same reason.
|
||||||
// It is not a value src/ never writes — for three of these fields it is the
|
// It is not a value src/ never writes — for three of these fields it is the
|
||||||
@@ -139,6 +158,23 @@ const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
|
|||||||
// falsy value stored under that field comes back TRUTHY from the floor, so no
|
// falsy value stored under that field comes back TRUTHY from the floor, so no
|
||||||
// `!state.x` branch is reachable from a stored record at all.
|
// `!state.x` branch is reachable from a stored record at all.
|
||||||
|
|
||||||
|
// The `hostileRestore` entries for a value driven onto every restorable view:
|
||||||
|
// it falls back to Home on the views listed in `fallsBackOn` and lands on every
|
||||||
|
// other one, so a view added to RESTORABLE_VIEWS is driven, and expected to
|
||||||
|
// land, without editing the row.
|
||||||
|
function everyRestorableView(value, fallsBackOn) {
|
||||||
|
return [
|
||||||
|
{ value, views: fallsBackOn, restored: false },
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
views: [...RESTORABLE_VIEWS].filter(
|
||||||
|
(view) => !fallsBackOn.includes(view),
|
||||||
|
),
|
||||||
|
restored: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const CONTRACT = [
|
const CONTRACT = [
|
||||||
{
|
{
|
||||||
field: "wallets",
|
field: "wallets",
|
||||||
@@ -280,28 +316,38 @@ const CONTRACT = [
|
|||||||
kind: KIND.SCALAR,
|
kind: KIND.SCALAR,
|
||||||
// The prototype members are the whole point: `wallets["map"]` is
|
// The prototype members are the whole point: `wallets["map"]` is
|
||||||
// TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and
|
// TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and
|
||||||
// `.addresses[…]` throws. A stale INTEGER is the safe case.
|
// `.addresses[…]` throws. A stale INTEGER is the safe case and has to
|
||||||
hostile: ["map", "__proto__", { a: 1 }],
|
// stay so. 5 is one, out of range for the one wallet in the profile,
|
||||||
|
// and it is also this field's truthy polarity: every other value here
|
||||||
|
// comes back from the floor as null.
|
||||||
|
hostile: ["map", "__proto__", { a: 1 }, 5],
|
||||||
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
||||||
holds: isIndexOrNull,
|
holds: isIndexOrNull,
|
||||||
// SCALAR, and swept anyway: the restore path is precisely why this
|
// SCALAR, and swept anyway: the restore path is precisely why this
|
||||||
// field gained a floor, so the sweep is the regression guard on it.
|
// field gained a floor, so the sweep is the regression guard on it.
|
||||||
alsoSweep: true,
|
alsoSweep: true,
|
||||||
routes: true,
|
routes: true,
|
||||||
// A stale INTEGER index, which reaches the restore path by a different
|
// Comes back from the floor as null, which hasValidAddress() reads as
|
||||||
// route from the prototype members above — falsy or out of range
|
// nothing selected: the popup falls back to Home on the five views
|
||||||
// rather than truthy — and has to keep being the safe case.
|
// that need an address, and lands on every other one.
|
||||||
hostileRestore: [{ value: "length" }, { value: 5 }],
|
hostileRestore: everyRestorableView("length", [
|
||||||
|
"address",
|
||||||
|
"address-token",
|
||||||
|
"receive",
|
||||||
|
"transaction",
|
||||||
|
"confirm-tx",
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "selectedAddress",
|
field: "selectedAddress",
|
||||||
kind: KIND.SCALAR,
|
kind: KIND.SCALAR,
|
||||||
hostile: ["map", "__proto__", { a: 1 }],
|
// 5 for the same reason as in selectedWallet: a stale index, and the
|
||||||
|
// one value here still truthy after the floor.
|
||||||
|
hostile: ["map", "__proto__", { a: 1 }, 5],
|
||||||
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
||||||
holds: isIndexOrNull,
|
holds: isIndexOrNull,
|
||||||
alsoSweep: true,
|
alsoSweep: true,
|
||||||
routes: true,
|
routes: true,
|
||||||
hostileRestore: [{ value: 5 }],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "currentView",
|
field: "currentView",
|
||||||
@@ -325,7 +371,9 @@ const CONTRACT = [
|
|||||||
// container shapes below onto every restorable view; hostileRestore
|
// container shapes below onto every restorable view; hostileRestore
|
||||||
// adds the records that PASS a branch's gate and then hand its
|
// adds the records that PASS a branch's gate and then hand its
|
||||||
// renderer something it dereferences, which is where the entries are
|
// renderer something it dereferences, which is where the entries are
|
||||||
// actually decided.
|
// actually decided. The guard refuses each single-view record below,
|
||||||
|
// so each is declared to fall back to Home: one that started landing
|
||||||
|
// would be reaching the renderer it was written against.
|
||||||
hostile: [42, "notarecord", { a: 1 }, [1, 2]],
|
hostile: [42, "notarecord", { a: 1 }, [1, 2]],
|
||||||
// `structuredClone(saved.viewData || {})`: the container is never falsy
|
// `structuredClone(saved.viewData || {})`: the container is never falsy
|
||||||
// in state whatever was stored, so no `!state.viewData` branch exists to
|
// in state whatever was stored, so no `!state.viewData` branch exists to
|
||||||
@@ -334,11 +382,20 @@ const CONTRACT = [
|
|||||||
hostileRestore: [
|
hostileRestore: [
|
||||||
// success-tx passes on `data.hash`, and renderSuccess() then calls
|
// success-tx passes on `data.hash`, and renderSuccess() then calls
|
||||||
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
|
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
|
||||||
{ value: { hash: "0x1" }, views: ["success-tx"] },
|
{
|
||||||
{ value: { hash: "0x1", to: 42 }, views: ["success-tx"] },
|
value: { hash: "0x1" },
|
||||||
|
views: ["success-tx"],
|
||||||
|
restored: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: { hash: "0x1", to: 42 },
|
||||||
|
views: ["success-tx"],
|
||||||
|
restored: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
|
value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
|
||||||
views: ["success-tx"],
|
views: ["success-tx"],
|
||||||
|
restored: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: {
|
value: {
|
||||||
@@ -347,12 +404,25 @@ const CONTRACT = [
|
|||||||
decoded: { details: [{ address: 42 }] },
|
decoded: { details: [{ address: 42 }] },
|
||||||
},
|
},
|
||||||
views: ["success-tx"],
|
views: ["success-tx"],
|
||||||
|
restored: false,
|
||||||
},
|
},
|
||||||
// error-tx passes on `data.message`, same dereference.
|
// error-tx passes on `data.message`, same dereference.
|
||||||
{ value: { message: "boom" }, views: ["error-tx"] },
|
{
|
||||||
{ value: { message: "boom", to: 42 }, views: ["error-tx"] },
|
value: { message: "boom" },
|
||||||
|
views: ["error-tx"],
|
||||||
|
restored: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: { message: "boom", to: 42 },
|
||||||
|
views: ["error-tx"],
|
||||||
|
restored: false,
|
||||||
|
},
|
||||||
// transaction passes on `data.tx`.
|
// transaction passes on `data.tx`.
|
||||||
{ value: { tx: { hash: "0x1" } }, views: ["transaction"] },
|
{
|
||||||
|
value: { tx: { hash: "0x1" } },
|
||||||
|
views: ["transaction"],
|
||||||
|
restored: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: {
|
value: {
|
||||||
tx: {
|
tx: {
|
||||||
@@ -363,9 +433,14 @@ const CONTRACT = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
views: ["transaction"],
|
views: ["transaction"],
|
||||||
|
restored: false,
|
||||||
},
|
},
|
||||||
// confirm-tx passes on `data.pendingTx`.
|
// confirm-tx passes on `data.pendingTx`.
|
||||||
{ value: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] },
|
{
|
||||||
|
value: { pendingTx: { amount: "1" } },
|
||||||
|
views: ["confirm-tx"],
|
||||||
|
restored: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: {
|
value: {
|
||||||
pendingTx: {
|
pendingTx: {
|
||||||
@@ -376,6 +451,7 @@ const CONTRACT = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
views: ["confirm-tx"],
|
views: ["confirm-tx"],
|
||||||
|
restored: false,
|
||||||
},
|
},
|
||||||
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked
|
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked
|
||||||
// the fields below it since it was written, and this is the
|
// the fields below it since it was written, and this is the
|
||||||
@@ -388,20 +464,29 @@ const CONTRACT = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
views: ["wait-tx"],
|
views: ["wait-tx"],
|
||||||
|
restored: false,
|
||||||
},
|
},
|
||||||
// A record that passes EVERY branch's gate at once, driven onto
|
// A record that passes EVERY branch's gate at once, driven onto
|
||||||
// every restorable view: a branch a view does not read must stay
|
// every restorable view: a branch a view does not read must stay
|
||||||
// one it does not read, and each renderer must survive the fields
|
// one it does not read. The five views with a viewData branch
|
||||||
// another branch left behind.
|
// refuse it; every other one renders it, and must survive the
|
||||||
|
// fields every branch left behind.
|
||||||
|
...everyRestorableView(
|
||||||
{
|
{
|
||||||
value: {
|
|
||||||
hash: "0x1",
|
hash: "0x1",
|
||||||
message: "boom",
|
message: "boom",
|
||||||
tx: { hash: "0x1" },
|
tx: { hash: "0x1" },
|
||||||
pendingTx: { amount: "1" },
|
pendingTx: { amount: "1" },
|
||||||
pendingWait: { hash: "0x1" },
|
pendingWait: { hash: "0x1" },
|
||||||
},
|
},
|
||||||
},
|
[
|
||||||
|
"confirm-tx",
|
||||||
|
"transaction",
|
||||||
|
"wait-tx",
|
||||||
|
"success-tx",
|
||||||
|
"error-tx",
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -583,6 +668,11 @@ const HEALTHY = { errors: [], blank: false };
|
|||||||
// set is all-truthy by construction, so without a falsy slot a dereference
|
// set is all-truthy by construction, so without a falsy slot a dereference
|
||||||
// behind `if (!state.x)` is never reached on the boot that corrupts x — the
|
// behind `if (!state.x)` is never reached on the boot that corrupts x — the
|
||||||
// same falsy-collapse blind spot the fields below were floored for.
|
// same falsy-collapse blind spot the fields below were floored for.
|
||||||
|
//
|
||||||
|
// Only `hostile` and `falsy` values count. Those go through the sweep below,
|
||||||
|
// which covers every restorable view; a `hostileRestore` entry is driven onto
|
||||||
|
// only the views it names, so a polarity it alone supplied might reach a single
|
||||||
|
// renderer.
|
||||||
describe("both polarities of every swept field are driven", () => {
|
describe("both polarities of every swept field are driven", () => {
|
||||||
const FALSY_STORED = [0, "", false, null];
|
const FALSY_STORED = [0, "", false, null];
|
||||||
const floored = (field, value) =>
|
const floored = (field, value) =>
|
||||||
@@ -606,10 +696,9 @@ describe("both polarities of every swept field are driven", () => {
|
|||||||
test(`${row.field}: truthy and falsy`, () => {
|
test(`${row.field}: truthy and falsy`, () => {
|
||||||
// What the boots below actually drive, floored the way a renderer
|
// What the boots below actually drive, floored the way a renderer
|
||||||
// sees it — not what the row says it drives.
|
// sees it — not what the row says it drives.
|
||||||
const driven = [
|
const driven = sweptValues(row).map((value) =>
|
||||||
...sweptValues(row),
|
floored(row.field, value),
|
||||||
...(row.hostileRestore || []).map((entry) => entry.value),
|
);
|
||||||
].map((value) => floored(row.field, value));
|
|
||||||
|
|
||||||
expect({
|
expect({
|
||||||
truthy: driven.some((value) => Boolean(value)),
|
truthy: driven.some((value) => Boolean(value)),
|
||||||
@@ -865,20 +954,27 @@ describe("every field the router does not read, corrupted at once, onto", () =>
|
|||||||
|
|
||||||
// The values that only mean something on the restore path: a viewData that
|
// The values that only mean something on the restore path: a viewData that
|
||||||
// PASSES a branch's gate and then hands its renderer something dereferenced,
|
// PASSES a branch's gate and then hands its renderer something dereferenced,
|
||||||
// and the index values whose route through hasValidAddress() differs from the
|
// and a selectedWallet the floor turns into nothing selected. Each boot must
|
||||||
// row's own hostile set.
|
// throw nothing and show exactly the view its entry says it lands on: its own,
|
||||||
|
// or Home, so a value written for one renderer cannot stop reaching it and
|
||||||
|
// still pass.
|
||||||
describe("a restore-only hostile value onto", () => {
|
describe("a restore-only hostile value onto", () => {
|
||||||
for (const row of CONTRACT) {
|
for (const row of CONTRACT) {
|
||||||
for (const entry of row.hostileRestore || []) {
|
for (const entry of row.hostileRestore || []) {
|
||||||
for (const view of entry.views || RESTORABLE_VIEWS) {
|
for (const view of entry.views) {
|
||||||
test(`${view}: ${row.field} = ${JSON.stringify(
|
test(`${view}: ${row.field} = ${JSON.stringify(
|
||||||
entry.value,
|
entry.value,
|
||||||
)}`, async () => {
|
)}`, async () => {
|
||||||
await expect(
|
const env = await bootPopup(
|
||||||
bootHealth(
|
|
||||||
restoringOnto(view, { [row.field]: entry.value }),
|
restoringOnto(view, { [row.field]: entry.value }),
|
||||||
),
|
);
|
||||||
).resolves.toEqual(HEALTHY);
|
expect({
|
||||||
|
errors: env.pageErrors,
|
||||||
|
visible: env.visibleViews(),
|
||||||
|
}).toEqual({
|
||||||
|
errors: [],
|
||||||
|
visible: [entry.restored ? view : "main"],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ describe("vendored blocklist", () => {
|
|||||||
// than loudly, so the ordering the search depends on is asserted here
|
// than loudly, so the ordering the search depends on is asserted here
|
||||||
// against the committed file rather than assumed of the generator.
|
// against the committed file rather than assumed of the generator.
|
||||||
// One assertion at the end rather than one per entry: 100k+ expect()
|
// One assertion at the end rather than one per entry: 100k+ expect()
|
||||||
// calls cost seconds, and make test is capped at 30 for the whole
|
// calls cost seconds, and make test is capped at 60 seconds for the
|
||||||
// suite. The index of the first offender is reported, so a failure
|
// whole suite. The index of the first offender is reported, so a
|
||||||
// still says where.
|
// failure still says where.
|
||||||
let previous = "";
|
let previous = "";
|
||||||
let outOfOrderAt = -1;
|
let outOfOrderAt = -1;
|
||||||
for (let i = 0; i < vendored.count; i++) {
|
for (let i = 0; i < vendored.count; i++) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+51
-2
@@ -65,7 +65,7 @@ jest.mock("../src/shared/log", () => ({
|
|||||||
debugf: () => {},
|
debugf: () => {},
|
||||||
infof: () => {},
|
infof: () => {},
|
||||||
warnf: () => {},
|
warnf: () => {},
|
||||||
errorf: () => {},
|
errorf: jest.fn(),
|
||||||
},
|
},
|
||||||
// The explorer's token list, which refreshBalances() also fetches.
|
// The explorer's token list, which refreshBalances() also fetches.
|
||||||
debugFetch: jest.fn(async () => ({
|
debugFetch: jest.fn(async () => ({
|
||||||
@@ -143,6 +143,7 @@ const { Transaction, Wallet, formatEther } = require("ethers");
|
|||||||
const { refreshBalances } = require("../src/shared/balances");
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
const { encryptWithPassword } = require("../src/shared/vault");
|
const { encryptWithPassword } = require("../src/shared/vault");
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
|
const { log } = require("../src/shared/log");
|
||||||
const send = require("../src/popup/views/send");
|
const send = require("../src/popup/views/send");
|
||||||
const confirmTx = require("../src/popup/views/confirmTx");
|
const confirmTx = require("../src/popup/views/confirmTx");
|
||||||
|
|
||||||
@@ -226,10 +227,16 @@ function tokenRow(value, decimals = "18") {
|
|||||||
// The confirmation screen Review leads to, once shown.
|
// The confirmation screen Review leads to, once shown.
|
||||||
let confirmed = null;
|
let confirmed = null;
|
||||||
|
|
||||||
|
// Stands in for the popup's: aborting it is the popup closing.
|
||||||
|
let pageClosed;
|
||||||
|
|
||||||
// Open the Send screen for `token` ("ETH" or a token address), with the
|
// Open the Send screen for `token` ("ETH" or a token address), with the
|
||||||
// recipient entered.
|
// recipient entered.
|
||||||
function openSend(token = "ETH") {
|
function openSend(token = "ETH") {
|
||||||
send.init({ showConfirmTx: (info) => (confirmed = info) });
|
send.init({
|
||||||
|
showConfirmTx: (info) => (confirmed = info),
|
||||||
|
pageClosed: pageClosed.signal,
|
||||||
|
});
|
||||||
confirmTx.init({});
|
confirmTx.init({});
|
||||||
send.resetSendValidation();
|
send.resetSendValidation();
|
||||||
state.currentView = "send";
|
state.currentView = "send";
|
||||||
@@ -260,6 +267,8 @@ function canSend() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
elements.clear();
|
elements.clear();
|
||||||
|
log.errorf.mockClear();
|
||||||
|
pageClosed = new AbortController();
|
||||||
confirmed = null;
|
confirmed = null;
|
||||||
state.selectedToken = null;
|
state.selectedToken = null;
|
||||||
state.trackedTokens = [];
|
state.trackedTokens = [];
|
||||||
@@ -447,6 +456,46 @@ describe("Max on an ETH send", () => {
|
|||||||
expect(text("flash-msg")).toBe("");
|
expect(text("flash-msg")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Holds the node's fee answer until the returned function fails it, as a
|
||||||
|
// node that cannot be reached, or a request the popup's closing
|
||||||
|
// cancelled, does.
|
||||||
|
function holdFailingFeeEstimate() {
|
||||||
|
let fail;
|
||||||
|
mockNode.feeData = new Promise((_, reject) => {
|
||||||
|
fail = () => reject(new TypeError("Failed to fetch"));
|
||||||
|
});
|
||||||
|
return fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("reports a fee estimate that fails while the popup is open", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI);
|
||||||
|
openSend();
|
||||||
|
const fail = holdFailingFeeEstimate();
|
||||||
|
const pressed = pressMax();
|
||||||
|
fail();
|
||||||
|
await pressed;
|
||||||
|
expect(log.errorf).toHaveBeenCalledWith(
|
||||||
|
"max amount fee estimate failed:",
|
||||||
|
"Failed to fetch",
|
||||||
|
);
|
||||||
|
expect(text("flash-msg")).toBe(
|
||||||
|
"The network fee could not be estimated.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/475
|
||||||
|
test("does not report a fee estimate that fails once the popup has closed", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI);
|
||||||
|
openSend();
|
||||||
|
const fail = holdFailingFeeEstimate();
|
||||||
|
const pressed = pressMax();
|
||||||
|
pageClosed.abort();
|
||||||
|
fail();
|
||||||
|
await pressed;
|
||||||
|
expect(log.errorf).not.toHaveBeenCalled();
|
||||||
|
expect(el("send-amount").value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
test("fills in once the held fee estimate arrives with nothing changed", async () => {
|
test("fills in once the held fee estimate arrives with nothing changed", async () => {
|
||||||
await refreshWith(BALANCE_WEI);
|
await refreshWith(BALANCE_WEI);
|
||||||
openSend();
|
openSend();
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ function element(id) {
|
|||||||
return (elements[id] ||= fakeElement());
|
return (elements[id] ||= fakeElement());
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSettingsView() {
|
// `pageClosed` stands in for the popup's: aborting it is the popup closing.
|
||||||
|
function loadSettingsView(pageClosed = new AbortController()) {
|
||||||
elements = {};
|
elements = {};
|
||||||
flashes = [];
|
flashes = [];
|
||||||
|
|
||||||
@@ -74,7 +75,9 @@ function loadSettingsView() {
|
|||||||
state.blockscoutUrl = SAVED_BLOCKSCOUT;
|
state.blockscoutUrl = SAVED_BLOCKSCOUT;
|
||||||
require("../src/shared/log").setRuntimeDebug(true);
|
require("../src/shared/log").setRuntimeDebug(true);
|
||||||
|
|
||||||
require("../src/popup/views/settings").init({});
|
require("../src/popup/views/settings").init({
|
||||||
|
pageClosed: pageClosed.signal,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(fieldId, buttonId, typed) {
|
async function save(fieldId, buttonId, typed) {
|
||||||
@@ -146,3 +149,21 @@ test("the Blockscout check of a URL with a user name and password", async () =>
|
|||||||
const line = expectFailedWithoutSecrets("Blockscout validation failed");
|
const line = expectFailedWithoutSecrets("Blockscout validation failed");
|
||||||
expect(line).toContain("https://explorer.example.invalid");
|
expect(line).toContain("https://explorer.example.invalid");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// In the popup a check its own closing cancelled fails as these do, and is
|
||||||
|
// not reported (https://git.eeqj.de/sneak/AutistMask/issues/475). Nothing is
|
||||||
|
// saved either.
|
||||||
|
test("a check that fails once the popup has closed is not reported", async () => {
|
||||||
|
const pageClosed = new AbortController();
|
||||||
|
pageClosed.abort();
|
||||||
|
loadSettingsView(pageClosed);
|
||||||
|
await save("settings-rpc", "btn-save-rpc", RPC_UNPARSEABLE);
|
||||||
|
await save(
|
||||||
|
"settings-blockscout",
|
||||||
|
"btn-save-blockscout",
|
||||||
|
BLOCKSCOUT_WITH_PASSWORD,
|
||||||
|
);
|
||||||
|
expect(console.error).not.toHaveBeenCalled();
|
||||||
|
expect(state.rpcUrl).toBe(SAVED_RPC);
|
||||||
|
expect(state.blockscoutUrl).toBe(SAVED_BLOCKSCOUT);
|
||||||
|
});
|
||||||
|
|||||||
+108
-7
@@ -1,12 +1,17 @@
|
|||||||
// Tests for the recovery phrase display (issue #161).
|
// Tests for the recovery phrase display (issue #161).
|
||||||
//
|
//
|
||||||
// These cover the parts that do not need a DOM: which wallet types may be
|
// These cover which wallet types may be offered the action at all, the
|
||||||
// offered the action at all, the exclusion of the screen from the set of
|
// exclusion of the screen from the set of views the popup may reopen onto,
|
||||||
// views the popup may reopen onto, and the absence of any path from this
|
// the absence of any path from this module to the logger, and, against a
|
||||||
// module to the logger. The DOM behaviour it guards — nothing rendered
|
// minimal DOM stub, where Back goes after the screen is left by the settings
|
||||||
// before the password is accepted, a wrong password revealing nothing, and
|
// gear or by its own Back. The rest of the DOM behaviour it guards — nothing rendered before the
|
||||||
// the wipe on leaving — is driven against the real popup in a real browser
|
// password is accepted, a wrong password revealing nothing, and the wipe on
|
||||||
// by tests/e2e/run.js, which is where every other view behaviour is tested.
|
// leaving — is driven against the real popup in a real browser by
|
||||||
|
// tests/e2e/run.js, which is where every other view behaviour is tested.
|
||||||
|
|
||||||
|
jest.mock("../src/shared/vault", () => ({
|
||||||
|
decryptWithPassword: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
@@ -74,6 +79,102 @@ describe("views the popup may reopen onto", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Just enough document for helpers.showView() and this view: every element
|
||||||
|
// is made on first lookup and keeps what the view writes to it, its click
|
||||||
|
// handler included.
|
||||||
|
function makeDocument() {
|
||||||
|
const els = new Map();
|
||||||
|
function makeElement() {
|
||||||
|
const classes = new Set();
|
||||||
|
const el = {
|
||||||
|
textContent: "",
|
||||||
|
value: "",
|
||||||
|
style: {},
|
||||||
|
classList: {
|
||||||
|
add: (name) => classes.add(name),
|
||||||
|
remove: (name) => classes.delete(name),
|
||||||
|
contains: (name) => classes.has(name),
|
||||||
|
toggle: (name, on) =>
|
||||||
|
on ? classes.add(name) : classes.delete(name),
|
||||||
|
},
|
||||||
|
addEventListener: (name, fn) => {
|
||||||
|
if (name === "click") el.onClick = fn;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
getElementById(id) {
|
||||||
|
// Created on demand by helpers.js; absent on a mainnet popup
|
||||||
|
// that is not a debug build.
|
||||||
|
if (id === "debug-banner") return null;
|
||||||
|
if (!els.has(id)) els.set(id, makeElement());
|
||||||
|
return els.get(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Back from Settings after leaving by the settings gear", () => {
|
||||||
|
// On Settings, opened from Home.
|
||||||
|
function load() {
|
||||||
|
jest.resetModules();
|
||||||
|
globalThis.document = makeDocument();
|
||||||
|
const helpers = loadHelpers();
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const showPhrase = require("../src/popup/views/showPhrase");
|
||||||
|
showPhrase.init();
|
||||||
|
state.wallets = [{ name: "Wallet 1", type: "hd", addresses: [] }];
|
||||||
|
state.currentView = "settings";
|
||||||
|
state.viewStack = ["main"];
|
||||||
|
return { helpers, state, showPhrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/461: leaving drops the
|
||||||
|
// wallet selection, so Back onto this screen showed a password prompt
|
||||||
|
// that could only answer "No wallet is selected." Taking the screen off
|
||||||
|
// the stack leaves Settings under Settings, and Back must not land there
|
||||||
|
// either (https://git.eeqj.de/sneak/AutistMask/issues/481).
|
||||||
|
test("goes to the screen under Settings", () => {
|
||||||
|
const { helpers, state, showPhrase } = load();
|
||||||
|
|
||||||
|
// Opened from the wallet list in Settings, then left by the gear:
|
||||||
|
// push the current view, then show Settings.
|
||||||
|
showPhrase.show(0);
|
||||||
|
helpers.pushCurrentView();
|
||||||
|
helpers.showView("settings");
|
||||||
|
|
||||||
|
expect(state.viewStack).toEqual(["main", "settings"]);
|
||||||
|
helpers.goBack();
|
||||||
|
expect(state.currentView).toBe("main");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each round trip leaves one more Settings under Settings.
|
||||||
|
test("goes to the screen under Settings after two round trips", () => {
|
||||||
|
const { helpers, state, showPhrase } = load();
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
showPhrase.show(0);
|
||||||
|
helpers.pushCurrentView();
|
||||||
|
helpers.showView("settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(state.viewStack).toEqual(["main", "settings", "settings"]);
|
||||||
|
helpers.goBack();
|
||||||
|
expect(state.currentView).toBe("main");
|
||||||
|
});
|
||||||
|
|
||||||
|
// This Back takes Settings off the stack before the screen is left, so
|
||||||
|
// the stack's top is then the entry Back from Settings will need.
|
||||||
|
test("its own Back button leaves the rest of the stack alone", () => {
|
||||||
|
const { state, showPhrase } = load();
|
||||||
|
showPhrase.show(0);
|
||||||
|
|
||||||
|
globalThis.document.getElementById("btn-show-phrase-back").onClick();
|
||||||
|
|
||||||
|
expect(state.currentView).toBe("settings");
|
||||||
|
expect(state.viewStack).toEqual(["main"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("the phrase cannot reach the logger", () => {
|
describe("the phrase cannot reach the logger", () => {
|
||||||
const source = fs.readFileSync(
|
const source = fs.readFileSync(
|
||||||
path.join(__dirname, "..", "src", "popup", "views", "showPhrase.js"),
|
path.join(__dirname, "..", "src", "popup", "views", "showPhrase.js"),
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// The two add-token screens do not report a token lookup the popup's own
|
||||||
|
// closing cancelled, and still report one that failed while the popup was open
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/475).
|
||||||
|
//
|
||||||
|
// In the popup a cancelled request fails with the same "Failed to fetch" as a
|
||||||
|
// server that cannot be reached, so every RPC request here fails that way, and
|
||||||
|
// only the signal the popup aborts on pagehide tells the two cases apart. The
|
||||||
|
// real lookupTokenInfo() runs, which logs a failed lookup itself before the
|
||||||
|
// screen does. Driven against the fake elements tests/flashLine.test.js uses.
|
||||||
|
// The last tests call lookupTokenInfo() directly with symbol() answered, so
|
||||||
|
// that its later calls are the ones that fail.
|
||||||
|
|
||||||
|
const {
|
||||||
|
FetchRequest,
|
||||||
|
Interface,
|
||||||
|
toUtf8Bytes,
|
||||||
|
toUtf8String,
|
||||||
|
} = require("ethers");
|
||||||
|
const { ERC20_ABI } = require("../src/shared/constants");
|
||||||
|
|
||||||
|
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||||
|
const RPC_URL = "https://rpc.example.invalid";
|
||||||
|
|
||||||
|
let elements;
|
||||||
|
|
||||||
|
function fakeElement() {
|
||||||
|
return {
|
||||||
|
value: "",
|
||||||
|
textContent: "",
|
||||||
|
style: {},
|
||||||
|
listeners: {},
|
||||||
|
addEventListener(event, handler) {
|
||||||
|
this.listeners[event] = handler;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stands in for document.getElementById(): one fake element per id.
|
||||||
|
function element(id) {
|
||||||
|
return (elements[id] ||= fakeElement());
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.doMock("../src/popup/views/helpers", () => ({
|
||||||
|
$: element,
|
||||||
|
showView: () => {},
|
||||||
|
showFlash: () => {},
|
||||||
|
escapeHtml: (s) => s,
|
||||||
|
goBack: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// state.js reads chrome.storage.local at load.
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
|
||||||
|
let logged;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
elements = {};
|
||||||
|
logged = [];
|
||||||
|
state.trackedTokens = [];
|
||||||
|
for (const method of ["warn", "error"]) {
|
||||||
|
jest.spyOn(console, method).mockImplementation((...args) => {
|
||||||
|
logged.push(args.map(String).join(" "));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
FetchRequest.registerGetUrl(async () => {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
["addToken", "add-token-address", "btn-add-token-confirm"],
|
||||||
|
[
|
||||||
|
"settingsAddToken",
|
||||||
|
"settings-addtoken-address",
|
||||||
|
"btn-settings-addtoken-manual",
|
||||||
|
],
|
||||||
|
])("looking up a token on %s", (view, field, button) => {
|
||||||
|
// Clicks the screen's add button for ADDRESS and waits for the lookup.
|
||||||
|
async function add(pageClosed) {
|
||||||
|
require("../src/popup/views/" + view).init({
|
||||||
|
doRefreshAndRender: () => {},
|
||||||
|
pageClosed,
|
||||||
|
});
|
||||||
|
element(field).value = ADDRESS;
|
||||||
|
await element(button).listeners.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a failure while the popup is open is reported", async () => {
|
||||||
|
await add(new AbortController().signal);
|
||||||
|
expect(logged).toHaveLength(2);
|
||||||
|
expect(logged[0]).toContain("symbol() failed:");
|
||||||
|
expect(logged[1]).toBe(
|
||||||
|
"[AutistMask] Adding token failed for " +
|
||||||
|
ADDRESS +
|
||||||
|
" Not a valid ERC-20 token (symbol() failed).",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failure once the popup has closed is not", async () => {
|
||||||
|
const pageClosed = new AbortController();
|
||||||
|
pageClosed.abort();
|
||||||
|
await add(pageClosed.signal);
|
||||||
|
expect(logged).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Answers the contract call for each function named in `answers` with its
|
||||||
|
// value, and fails every other request as above.
|
||||||
|
function answer(answers) {
|
||||||
|
const erc20 = new Interface(ERC20_ABI);
|
||||||
|
FetchRequest.registerGetUrl(async (req) => {
|
||||||
|
const { id, params } = JSON.parse(toUtf8String(req.body));
|
||||||
|
const { name } = erc20.parseTransaction({ data: params[0].data });
|
||||||
|
if (!(name in answers)) {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
}
|
||||||
|
const result = erc20.encodeFunctionResult(name, [answers[name]]);
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
statusMessage: "OK",
|
||||||
|
headers: {},
|
||||||
|
body: toUtf8Bytes(JSON.stringify({ jsonrpc: "2.0", id, result })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
["decimals() fails", { symbol: "TKN" }, "decimals() failed:"],
|
||||||
|
[
|
||||||
|
"name() fails",
|
||||||
|
{ symbol: "TKN", decimals: 18 },
|
||||||
|
"name() failed, using symbol as name:",
|
||||||
|
],
|
||||||
|
])("a token lookup where %s", (_, answers, report) => {
|
||||||
|
async function lookUp(pageClosed) {
|
||||||
|
// A token found is logged at info level, which is not under test.
|
||||||
|
jest.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
answer(answers);
|
||||||
|
// A failed decimals() also fails the lookup, which the screens report
|
||||||
|
// as tested above.
|
||||||
|
await lookupTokenInfo(ADDRESS, RPC_URL, "mainnet", pageClosed).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("is reported while the popup is open", async () => {
|
||||||
|
await lookUp(new AbortController().signal);
|
||||||
|
expect(logged).toEqual([expect.stringContaining(report)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is not once the popup has closed", async () => {
|
||||||
|
const pageClosed = new AbortController();
|
||||||
|
pageClosed.abort();
|
||||||
|
await lookUp(pageClosed.signal);
|
||||||
|
expect(logged).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// lookupTokenInfo() cuts the symbol and name it reads off a contract on
|
||||||
|
// code-point boundaries, as displaySymbol() counts them, so an emoji outside
|
||||||
|
// the Basic Multilingual Plane is never stored cut in half
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/458).
|
||||||
|
|
||||||
|
// An emoji outside the Basic Multilingual Plane: two UTF-16 units.
|
||||||
|
const FOX = "\u{1F98A}";
|
||||||
|
|
||||||
|
// The leading "A" puts every emoji at an odd UTF-16 offset, so a cut that
|
||||||
|
// counts UTF-16 units lands between the two halves of one.
|
||||||
|
const SYMBOL = "A" + FOX.repeat(12);
|
||||||
|
const NAME = "A" + FOX.repeat(64);
|
||||||
|
|
||||||
|
// The contract answers symbol(), decimals() and name() with the values above.
|
||||||
|
jest.doMock("ethers", () => ({
|
||||||
|
...jest.requireActual("ethers"),
|
||||||
|
Contract: function () {
|
||||||
|
return {
|
||||||
|
symbol: async () => SYMBOL,
|
||||||
|
decimals: async () => 18n,
|
||||||
|
name: async () => NAME,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
|
||||||
|
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||||
|
const RPC_URL = "https://rpc.example.invalid";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// A token found is logged at info level, which is not under test.
|
||||||
|
jest.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the symbol is cut to 12 code points, never inside an emoji", async () => {
|
||||||
|
const { symbol } = await lookupTokenInfo(ADDRESS, RPC_URL, "mainnet");
|
||||||
|
expect(symbol).toBe("A" + FOX.repeat(11));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the name is cut to 64 code points, never inside an emoji", async () => {
|
||||||
|
const { name } = await lookupTokenInfo(ADDRESS, RPC_URL, "mainnet");
|
||||||
|
expect(name).toBe("A" + FOX.repeat(63));
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// The address and token screens do not report a transaction list, or an ENS
|
||||||
|
// name lookup for the addresses in it, that the popup's own closing cancelled,
|
||||||
|
// and still report one that failed while the popup was open
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/475).
|
||||||
|
//
|
||||||
|
// In the popup a cancelled request fails with the same "Failed to fetch" as a
|
||||||
|
// server that cannot be reached, so the requests here fail that way, and only
|
||||||
|
// the signal the popup aborts on pagehide tells the two cases apart. Driven
|
||||||
|
// against a minimal DOM stub in the shape tests/timestampDisplay.test.js uses.
|
||||||
|
|
||||||
|
// The explorer answers with mockHistory, and fails when it is null.
|
||||||
|
let mockHistory = null;
|
||||||
|
jest.mock("../src/shared/transactions", () => ({
|
||||||
|
...jest.requireActual("../src/shared/transactions"),
|
||||||
|
fetchRecentTransactions: async () => {
|
||||||
|
if (mockHistory === null) throw new TypeError("Failed to fetch");
|
||||||
|
return mockHistory;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Every ENS name lookup fails.
|
||||||
|
jest.mock("../src/shared/balances", () => ({
|
||||||
|
...jest.requireActual("../src/shared/balances"),
|
||||||
|
getProvider: () => ({
|
||||||
|
lookupAddress: async () => {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const addressDetail = require("../src/popup/views/addressDetail");
|
||||||
|
const addressToken = require("../src/popup/views/addressToken");
|
||||||
|
|
||||||
|
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||||
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
|
// A transaction ADDRESS sent, as the history lists hold it.
|
||||||
|
function historyTx() {
|
||||||
|
return {
|
||||||
|
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
|
||||||
|
from: ADDRESS,
|
||||||
|
to: RECIPIENT,
|
||||||
|
value: "0.0000",
|
||||||
|
exactValue: "0.0",
|
||||||
|
rawAmount: "0",
|
||||||
|
rawUnit: "wei",
|
||||||
|
symbol: "ETH",
|
||||||
|
timestamp: 1790000000,
|
||||||
|
isError: false,
|
||||||
|
directionLabel: "Sent",
|
||||||
|
direction: "sent",
|
||||||
|
contractAddress: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeElement(id) {
|
||||||
|
const el = {
|
||||||
|
id,
|
||||||
|
textContent: "",
|
||||||
|
value: "",
|
||||||
|
innerHTML: "",
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
classList: {
|
||||||
|
add: () => {},
|
||||||
|
remove: () => {},
|
||||||
|
contains: () => false,
|
||||||
|
toggle: () => false,
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let logged;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockHistory = null;
|
||||||
|
logged = [];
|
||||||
|
jest.spyOn(console, "error").mockImplementation((...args) => {
|
||||||
|
logged.push(args.map(String).join(" "));
|
||||||
|
});
|
||||||
|
globalThis.document = makeDocument();
|
||||||
|
globalThis.window = { location: { search: "" } };
|
||||||
|
state.wallets = [
|
||||||
|
{
|
||||||
|
name: "Main",
|
||||||
|
type: "key",
|
||||||
|
addresses: [{ address: ADDRESS, balance: "0.0000" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.trackedTokens = [];
|
||||||
|
state.viewStack = [];
|
||||||
|
state.selectedWallet = 0;
|
||||||
|
state.selectedAddress = 0;
|
||||||
|
state.selectedToken = "ETH";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
["the address screen", addressDetail, "tx-list"],
|
||||||
|
["the token screen", addressToken, "address-token-tx-list"],
|
||||||
|
])("the transaction list on %s", (_name, view, listId) => {
|
||||||
|
// Open the screen and wait for its transaction list to load or fail.
|
||||||
|
async function open(pageClosed) {
|
||||||
|
view.init({ pageClosed });
|
||||||
|
view.show();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a failure while the popup is open is reported", async () => {
|
||||||
|
await open(new AbortController().signal);
|
||||||
|
expect(logged).toEqual([
|
||||||
|
"[AutistMask] loadTransactions failed: Failed to fetch",
|
||||||
|
]);
|
||||||
|
expect(node(listId).innerHTML).toContain(
|
||||||
|
"Failed to load transactions.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failure once the popup has closed is not", async () => {
|
||||||
|
const pageClosed = new AbortController();
|
||||||
|
pageClosed.abort();
|
||||||
|
await open(pageClosed.signal);
|
||||||
|
expect(logged).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a name lookup that fails while the popup is open is reported", async () => {
|
||||||
|
mockHistory = [historyTx()];
|
||||||
|
await open(new AbortController().signal);
|
||||||
|
expect(logged).toContain(
|
||||||
|
"[AutistMask] ENS reverse lookup failed " +
|
||||||
|
RECIPIENT +
|
||||||
|
" Failed to fetch",
|
||||||
|
);
|
||||||
|
expect(node(listId).innerHTML).toContain("tx-row");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a name lookup that fails once the popup has closed is not", async () => {
|
||||||
|
mockHistory = [historyTx()];
|
||||||
|
const pageClosed = new AbortController();
|
||||||
|
pageClosed.abort();
|
||||||
|
await open(pageClosed.signal);
|
||||||
|
expect(logged).toEqual([]);
|
||||||
|
expect(node(listId).innerHTML).toContain("tx-row");
|
||||||
|
});
|
||||||
|
});
|
||||||
+35
-2
@@ -21,7 +21,7 @@ jest.mock("../src/shared/log", () => ({
|
|||||||
debugf: () => {},
|
debugf: () => {},
|
||||||
infof: () => {},
|
infof: () => {},
|
||||||
warnf: () => {},
|
warnf: () => {},
|
||||||
errorf: () => {},
|
errorf: jest.fn(),
|
||||||
},
|
},
|
||||||
debugFetch: jest.fn(),
|
debugFetch: jest.fn(),
|
||||||
setRuntimeDebug: () => {},
|
setRuntimeDebug: () => {},
|
||||||
@@ -108,6 +108,7 @@ global.chrome = { storage };
|
|||||||
const txStatus = require("../src/popup/views/txStatus");
|
const txStatus = require("../src/popup/views/txStatus");
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||||
|
const { log } = require("../src/shared/log");
|
||||||
|
|
||||||
const TX_HASH =
|
const TX_HASH =
|
||||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
||||||
@@ -128,16 +129,24 @@ function waitStatusText() {
|
|||||||
return getElement("wait-tx-status").textContent;
|
return getElement("wait-tx-status").textContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stands in for the popup's: aborting it is the popup closing.
|
||||||
|
let pageClosed;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
|
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
|
||||||
elements.clear();
|
elements.clear();
|
||||||
mockReceiptLookup.mockReset();
|
mockReceiptLookup.mockReset();
|
||||||
|
log.errorf.mockClear();
|
||||||
state.wallets = [];
|
state.wallets = [];
|
||||||
state.viewData = {};
|
state.viewData = {};
|
||||||
state.viewStack = [];
|
state.viewStack = [];
|
||||||
state.currentView = null;
|
state.currentView = null;
|
||||||
txStatus.init({ doRefreshAndRender: jest.fn() });
|
pageClosed = new AbortController();
|
||||||
|
txStatus.init({
|
||||||
|
doRefreshAndRender: jest.fn(),
|
||||||
|
pageClosed: pageClosed.signal,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -466,6 +475,30 @@ describe("WaitTx against an RPC that never answers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// In the popup a lookup its own closing cancelled fails as a lookup against an
|
||||||
|
// RPC that cannot be reached does, and is not reported
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/475).
|
||||||
|
describe("WaitTx when the popup closes", () => {
|
||||||
|
test("a lookup that fails while the popup is open is reported", async () => {
|
||||||
|
mockReceiptLookup.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
await jest.advanceTimersByTimeAsync(10000);
|
||||||
|
expect(log.errorf).toHaveBeenCalledWith(
|
||||||
|
"poll receipt failed:",
|
||||||
|
"Failed to fetch",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a lookup that fails once the popup has closed is not", async () => {
|
||||||
|
mockReceiptLookup.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
|
txStatus.showWait(TX_INFO, TX_HASH);
|
||||||
|
pageClosed.abort();
|
||||||
|
await jest.advanceTimersByTimeAsync(10000);
|
||||||
|
expect(mockReceiptLookup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(log.errorf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("wait-tx is a view the popup may reopen onto", () => {
|
describe("wait-tx is a view the popup may reopen onto", () => {
|
||||||
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
|
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
|
||||||
// refuses any view not in the set, so dropping "wait-tx" from it kills
|
// refuses any view not in the set, so dropping "wait-tx" from it kills
|
||||||
|
|||||||
+2
-2
@@ -12,8 +12,8 @@
|
|||||||
// interactive parameters, which the module hardcodes. The parameters are not
|
// interactive parameters, which the module hardcodes. The parameters are not
|
||||||
// weakened or overridden anywhere in this file — they are pinned by the "key
|
// weakened or overridden anywhere in this file — they are pinned by the "key
|
||||||
// derivation cost" tests, since they are the vault's only defence against an
|
// derivation cost" tests, since they are the vault's only defence against an
|
||||||
// offline attack on a stolen blob. The suite is kept inside script/test's
|
// offline attack on a stolen blob. The suite is kept inside the 60-second
|
||||||
// 30-second budget by sharing one encrypted fixture across the tamper cases
|
// make test cap by sharing one encrypted fixture across the tamper cases
|
||||||
// instead of re-encrypting per test.
|
// instead of re-encrypting per test.
|
||||||
|
|
||||||
const sodium = require("libsodium-wrappers-sumo");
|
const sodium = require("libsodium-wrappers-sumo");
|
||||||
|
|||||||
@@ -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