Skip to content

Release: develop -> main - #67

Merged
TaprootFreak merged 33 commits into
mainfrom
develop
May 29, 2026
Merged

Release: develop -> main#67
TaprootFreak merged 33 commits into
mainfrom
develop

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automatic Release PR

Commits: 1 new commit(s)

  • Review all changes
  • Verify CI passes
  • Merge when ready for production

TaprootFreak and others added 26 commits May 17, 2026 22:48
* Add viewport toggle for handbook screens with multiple baselines

Merge desktop/tablet/mobile variant cards into a single card per
screen with a Mobile/Desktop[/Tablet] toggle. Mobile is the default,
since zkCoins is a mobile-first PWA. Affects Welcome, Settings,
Funded Balance, Send-Confirm Dialog and Receive Default.

The underlying E2E tests and baseline images are unchanged — only
the handbook presentation collapses redundant variants.

* Validate handbook ↔ e2e baseline coverage in sync script

The handbook references screenshots under /handbook/screenshots/<name>.png
that are hardlinked at build time from e2e/<spec>.spec.ts-snapshots/.
Until now, drift between the two sides (a removed e2e test still
documented, or a new baseline never linked from any card) only surfaced
as a runtime 404 on the live page.

Extend scripts/sync-handbook-baselines.mjs to scan both handbook HTMLs
after the sync runs and exit non-zero on either direction:

- handbook references a name with no matching baseline → fail
- baseline exists but no handbook card references it → fail

This runs automatically via `predev` and `prebuild`, so CI's Lint & Build
step now catches the drift before it ships.

Also fix the existing drift the validator surfaced: the `unlock-unlocking`
card was still in both handbooks even though that test was removed from
04-unlock-password.spec.ts. Drop the card and bring the per-spec /
top-level test+baseline counters in line (71/68 → 70/67, spec 04 5/5 → 4/4).
…ers (#66)

PR #64 widened the settings-network-badge timeout from 10 s to 30 s to
mask a race between WalletScreen's `useEffect(api.info, …)` and any
subsequent navigation that gates UI on `networkName !== ''`. Symptom
fix, not root cause: the test still races, just with more headroom.

Root cause: the helper that brings a test into the wallet does not
wait for the api.info roundtrip to settle. Any test that navigates to
/settings (or another networkName-gated route) immediately afterwards
sees an empty store.

Fix: add `waitForNetworkInfo` to e2e/_helpers/wallet.ts and call it
at the end of `aliceLogin`/`bobLogin`. It polls the zustand store
directly via the window-exposed `__useNetworkStore` (see
src/stores/network.ts) — already the pattern the 09-network-badge-
loading test uses to guard its own intercept.

Once the helper returns, every navigation to a networkName-gated UI
is deterministic. Restore the 05-disconnect goToSettings timeout to
the default 10 s; the wider value is no longer doing any work.
…helpers (#70)

Same pattern as the api.info root-cause fix (#66): the login helpers
now block on WalletScreen's first /api/balance tick before returning,
so any subsequent assertion is race-free.

Shared helper waitForBalanceLoaded in e2e/_helpers/wallet.ts polls the
data-loading attribute on balance-amount-usd. The attribute is true
while balance === null (post-mount loading) and absent once the first
tick lands — regardless of value. wallet-empty-banner visibility is
not a reliable loaded-signal because the banner only renders for
balance === 0; for a funded wallet it never appears.

aliceLogin / bobLogin in fixtures.ts now call both waitForNetworkInfo
and waitForBalanceLoaded at the end. Inline waitForAliceBalanceLoaded
in 07-send.spec.ts is removed — its work is now done in the helper.
The 02-create-seed wallet-after-create timeout is restored from 60s
(PR #64 widening) to the default 30s: with the deterministic balance
wait wired into the upstream helper this margin is no longer needed.
…nistically (#73)

Two follow-up fixes after PR #70 merged and revealed flake under
concurrent CI load (multiple branches running E2E against the same
dev.zkcoins.app at once):

1. wait helpers exit as soon as the underlying signal lands, so a
   larger upper bound is purely a safety margin and not wasted time:
   waitForNetworkInfo  15s -> 30s
   waitForBalanceLoaded 30s -> 60s
   These cover api.info and the first /api/balance tick when the
   shared DEV API is under load.

2. wallet-after-create in 02-create-seed.spec.ts does not go through
   aliceLogin (creates a fresh wallet inline), so the helper-level
   pre-warm never ran here. Call waitForBalanceLoaded directly before
   the wallet-empty-banner assertion, and drop the banner timeout to
   5s (the wait above is the real gate).
#72)

App-side half of #71. The full intended scope was App-side testid + audit-
script update + spec locator swap, but the spec swap cannot ship in this
PR: the E2E suite runs against the deployed DEV bundle
(https://dev.zkcoins.app), and the new testid is not present there until
this PR merges and DEV redeploys. The spec swap is tracked in #74 as a
post-deploy follow-up.

Changes in this PR:

- Add `data-testid="onboarding-step-back-btn"` to the StepHeader Back-Btn
  so the real MVP button is classified individually instead of waved
  through by a file-level exemption.
- Drop `Onboarding.tsx` from `MVP_EXEMPT_FILES`; add the two
  `FEATURES.PASSKEY`-gated buttons (PasskeyFlow register,
  PasskeyRestoreFlow authenticate) to `MVP_EXEMPT_BUTTON_SNIPPETS` —
  both are dead-stripped from the PRD bundle. The authenticate button
  pins on the unique label `Authenticate with passkey` rather than
  `onClick={restore}` (the latter also appears on the SeedImport submit
  button, which has its own testid).
- Add `onboarding-step-back-btn` to `MVP_EXEMPT_TESTIDS` with a
  TODO(#74) comment. The exemption disappears once #74 lands and the
  specs reference the testid.

Refs #71
…estid (#75)

Post-deploy follow-up to #72. The App-side testid landed in #72 and is
now live on https://dev.zkcoins.app, so the spec swap and the audit's
temporary exemption can ship.

- e2e/02-create-seed.spec.ts `back-from-reveal`: both clicks switch
  from `page.locator('button').first()` to
  `page.getByTestId('onboarding-step-back-btn')`. Click count and the
  DEV-vs-PRD branching logic stay as they are.
- e2e/03-restore-seed.spec.ts `back-from-input`: same swap on the single
  click.
- e2e/_audit/coverage.mjs: drop the `onboarding-step-back-btn`
  TODO(#74) entry from `MVP_EXEMPT_TESTIDS` — the testid is now covered
  by an e2e reference, so Section A picks it up automatically.

Closes #71
…69)

* test(e2e): add axe-core a11y regression spec (issue #68 W2)

Adds e2e/12-a11y.spec.ts: runs @axe-core/playwright with wcag2a/wcag2aa
tags against the six MVP routes (welcome, mid-onboarding seed-reveal,
wallet home, /send, /receive, /settings) and fails on any new serious
or critical violation. Allowlist mechanism (KNOWN_VIOLATIONS) is empty
by default — first PR's job is to surface existing violations, not
silence them. No screenshots, so the spec is intentionally excluded
from the visual-baseline regen workflow.

e2e/README.md gets a new \xa78.13 section describing the route matrix
and allowlist convention, and totals are bumped to 79/70.

* feat(api): validate HTTP responses with Zod at the boundary (issue #68 W3)

Every api.* call in src/lib/api/client.ts now parses its response
through a schema in src/lib/api/schemas.ts before returning. A
renamed or missing server field throws a ZodError at the boundary
instead of leaking undefined deep into a render.

Two layers of contract checking:

- Compile-time: existing client*.test.ts mocks are typed via
  z.infer<typeof Schema>, mirroring the WASM-mock pattern. Drift
  between a mock and its schema becomes a TS error.
- Runtime, opt-in: src/__tests__/lib/api/contract.live.test.ts is
  skipped unless RUN_API_CONTRACT=true. It performs a real
  /api/info plus a mint -> balance -> sendSigned round-trip
  against E2E_API_URL (default dev-api.zkcoins.app) and feeds the
  responses through the schemas. The manual api-contract.yml
  workflow runs it weekly and on workflow_dispatch.

README gains an 'API Client' section describing both layers.

* test(components): add SetPassword/SendForm/UnlockWallet tests (issue #68 W1)

Adds three component tests under @testing-library/react that catch
regressions in form validation without going through the 10-minute
e2e pipeline:

- SetPassword.test.tsx: drives SeedFlow to the password stage and
  asserts on the Create-button disabled state, the password-mismatch
  branch, the min-length branch, and that the inline error clears
  once a valid pair is accepted.
- SendForm.test.tsx: drives /send with a funded Alice fixture in
  the store and covers empty/invalid/exceeds-balance/set-max
  branches plus the success-cleared-error transition.
- UnlockWallet.test.tsx: exercises the unlock screen's password
  flow (empty disables submit, typed enables it, wrong password
  surfaces 'Incorrect password').

To make the unlock screen testable, UnlockScreen moves from
src/app/page.tsx (which Next.js does not allow extra exports
from) to src/components/onboarding/UnlockScreen.tsx. The prop
bag is unchanged, so the call site in Home is a one-line import
swap.

Plumbing changes:

- @testing-library/jest-dom matchers wired into the global vitest
  setup, alongside a cleanup() afterEach so getByTestId does not
  hit leftover trees from a previous test.
- @vitejs/plugin-react added so vitest can parse JSX in *.test.tsx.
- vitest.config.ts test.include extended with src/**/*.test.tsx.
- coverage.include is intentionally unchanged: tsx coverage stays
  out of the gate (regression safety, not coverage chasing).

* test(e2e): add axe-core a11y regression spec (issue #68 W2)

Adds e2e/12-a11y.spec.ts: runs @axe-core/playwright with wcag2a/wcag2aa
tags against the six MVP routes (welcome, mid-onboarding seed-reveal,
wallet home, /send, /receive, /settings) and fails on any new serious
or critical violation. Allowlist mechanism (KNOWN_VIOLATIONS) is empty
by default — first PR's job is to surface existing violations, not
silence them. No screenshots, so the spec is intentionally excluded
from the visual-baseline regen workflow.

e2e/README.md gets a new \xa78.13 section describing the route matrix
and allowlist convention, and totals are bumped to 79/70.

* feat(api): validate HTTP responses with Zod at the boundary (issue #68 W3)

Every api.* call in src/lib/api/client.ts now parses its response
through a schema in src/lib/api/schemas.ts before returning. A
renamed or missing server field throws a ZodError at the boundary
instead of leaking undefined deep into a render.

Two layers of contract checking:

- Compile-time: existing client*.test.ts mocks are typed via
  z.infer<typeof Schema>, mirroring the WASM-mock pattern. Drift
  between a mock and its schema becomes a TS error.
- Runtime, opt-in: src/__tests__/lib/api/contract.live.test.ts is
  skipped unless RUN_API_CONTRACT=true. It performs a real
  /api/info plus a mint -> balance -> sendSigned round-trip
  against E2E_API_URL (default dev-api.zkcoins.app) and feeds the
  responses through the schemas. The manual api-contract.yml
  workflow runs it weekly and on workflow_dispatch.

README gains an 'API Client' section describing both layers.

* test(components): add SetPassword/SendForm/UnlockWallet tests (issue #68 W1)

Adds three component tests under @testing-library/react that catch
regressions in form validation without going through the 10-minute
e2e pipeline:

- SetPassword.test.tsx: drives SeedFlow to the password stage and
  asserts on the Create-button disabled state, the password-mismatch
  branch, the min-length branch, and that the inline error clears
  once a valid pair is accepted.
- SendForm.test.tsx: drives /send with a funded Alice fixture in
  the store and covers empty/invalid/exceeds-balance/set-max
  branches plus the success-cleared-error transition.
- UnlockWallet.test.tsx: exercises the unlock screen's password
  flow (empty disables submit, typed enables it, wrong password
  surfaces 'Incorrect password').

To make the unlock screen testable, UnlockScreen moves from
src/app/page.tsx (which Next.js does not allow extra exports
from) to src/components/onboarding/UnlockScreen.tsx. The prop
bag is unchanged, so the call site in Home is a one-line import
swap.

Plumbing changes:

- @testing-library/jest-dom matchers wired into the global vitest
  setup, alongside a cleanup() afterEach so getByTestId does not
  hit leftover trees from a previous test.
- @vitejs/plugin-react added so vitest can parse JSX in *.test.tsx.
- vitest.config.ts test.include extended with src/**/*.test.tsx.
- coverage.include is intentionally unchanged: tsx coverage stays
  out of the gate (regression safety, not coverage chasing).
…obes (#76)

vitest's 5 s default times out the /api/mint and /api/balance probes
before the server's real ZK-proof generation completes (typical
10–20 s on DEV). The pre-merge version had a single 120 s timeout
on the combined round-trip test; when the test was split into three
endpoint-specific cases, only /api/info inherited the default — the
other two needed the same explicit override.

Confirmed by manual workflow_dispatch on develop (run 26004360092):
/api/info green, /api/mint + /api/balance timed out at 5 s.
Adds src/__tests__/app/send-pipeline.test.tsx (14 tests) for the
SendPage paths the existing SendForm.test.tsx and e2e/07-send.spec.ts
do not reach:

- Phase-1 /api/send + Phase-2 /api/commit round-trip, success screen,
  balance refresh, transaction row, request body shape.
- 3-attempt commit retry loop: success on attempt 2, exhaustion after
  attempts 1+2+3 with the user-visible "delivery failed" error and
  preserved inflight payload. Back-off setTimeout calls of 2 s / 4 s
  are intercepted to a microtask so the retry budget collapses; every
  other timer (incl. the 120 s AbortController guard in request())
  uses the real clock so the rest of the page lifecycle is untouched.
- In-flight commit crash recovery on mount: payload cleared on
  success, preserved on failure, malformed JSON tolerated.
- Username resolution branches gated by FEATURES.USERNAMES: @suffix,
  $prefix, hex fast-path, resolve failure surfacing the API error.
- Defensive branches: account.xpriv empty throws "No private key";
  no-account redirect honours the 100 ms grace window; redirect
  suppressed when the account lands inside that window.

Coverage thresholds unchanged — the surface remains src/lib/** and
src/stores/**, both still 100 % on every axis. SendPage itself
stays outside coverage scope (component, not lib/store), so the
tests are additive: 175 → 189 unit tests, no new dead code paths.
Adds src/__tests__/packages/zkcoins-wasm/fallback.test.ts (12 tests).
The setup-file mock for `@zkcoins/wasm` is bypassed via
`vi.importActual`, so the real `initWasm()` runs. wasm-bindgen's
client loads `client_bg.wasm` through `fetch(new URL(..., import.meta.url))`,
which cannot resolve under happy-dom (no `file://` fetch) — the
import rejects and `initWasm()` substitutes `createJsFallback()`.

Every fallback method is asserted to throw the WASM_REQUIRED
message so SendPage / Onboarding / the wallet store surface the
"reload the page or use a modern browser" prompt instead of
silently returning `undefined` and crashing further downstream.

Also covers the `wasmModule` singleton: repeated `initWasm()` calls
within one module evaluation return the same instance; resetting the
module cache produces a fresh fallback.
Adds src/__tests__/app/send-edge-cases.test.tsx (9 tests).
Complements `SendForm.test.tsx` (amount-field validation) and
`send-pipeline.test.tsx` (Phase-1 + Phase-2 round-trip) by targeting
conditional renders and state-preservation branches neither file
exercises:

- balance===null pre-tick: "— BTC" placeholder, data-loading
  attribute, Set max disabled, no-funds banner hidden.
- balance===0: no-funds banner visible, Set max still disabled.
- balance>0: formatted "1.00000000 BTC", Set max enabled.
- Recovering banner stays hidden without an inflight payload.
- Confirm card Cancel: recipient + amount inputs preserved,
  confirm card unmounts, submit button re-enabled.
- Confirm card content: typed amount rendered in compact BTC.
- handleConfirm guard: "Balance not loaded yet" surfaced when
  balance is null at submit time.
- Floating-point safety: 0.3 BTC → exactly 30_000_000 sats
  (asserts Math.round vs floor; without it, 0.3 * 1e8 in
  IEEE-754 would silently underflow to 29_999_999).
- 0.00000001 BTC → exactly 1 sat in the confirm card.
Adds src/__tests__/components/WalletScreen.polling.test.tsx (10 tests).
The 5 s `setInterval(api.balance, 5000)` in WalletScreen is the only
background work in the wallet UI and was previously asserted only by
e2e screenshots — which capture the post-poll DOM, not the timing
semantics, the cleanup behaviour, or the account-swap path.

Covers:
- mount tick fires immediately and writes balance to store
- interval fires every 5 s; balance overwrites each time
- unmount clears the interval (no ticks after a 60 s wall-time wait)
- empty account → no fetch
- account swap → new interval keyed to the new address
- thrown balance error is swallowed; store balance unchanged
- FEATURES.USERNAMES off → setUsername never called even if server returns one
- FEATURES.USERNAMES on + no existing username → username assigned on first tick
- FEATURES.USERNAMES on + existing username → second tick does not overwrite
- api.info on mount writes networkName to the network store
Adds src/__tests__/components/Onboarding-restore.test.tsx (13 tests).
Onboarding.tsx is 847 LOC and the only existing unit test
(SetPassword.test.tsx) covers the create-wallet password stage.
The restore path has its own BIP-39 validation, word-count guard,
and store-write semantics that fan out across WASM + auth store
+ wallet store + api.balance.

Covers:
- entry from Welcome via onboarding-restore-btn
- StepHeader Back returns to Welcome
- input validation: Continue disabled while empty; rejects non-12-word
  phrase; rejects 12-word phrase that fails BIP-39 list check
  (validateMnemonic mocked false for one call); accepts valid phrase;
  retyping clears the inline error eagerly via onChange
- password stage: Restore disabled while either field empty;
  min-8-char enforced; mismatch enforced
- restore side effects: account written, authMethod='seed' set,
  balance fetched
- balance fetch failure: non-fatal, restore still completes, balance
  stays null (WalletScreen poll picks it up later)
- createAccountFromMnemonic throws: stage rolls back to password,
  inline error shows the WASM error, no store writes happen
)

* fix(a11y): bump ink3 contrast, hide seed-grid indexes, title QR svg

Closes #77, #78, #79, #80, #81. Resolves the six axe-core violations
`12-a11y.spec.ts` pre-populated into `KNOWN_VIOLATIONS` on its
first CI run against DEV.

Three independent fixes, one shared root cause analysis (see
`fix/probe-a11y` run output captured below):

1. `tailwind.config.ts` — bump `ink3` from #71717a to #909099.
   The old token rendered at 4.04:1 on `bg-surface` (#0c0c0c)
   and failed WCAG AA color-contrast on 13 nodes across every
   MVP route: back links, section headers, descriptive body
   text, the `BTC` input suffix, the address chip on wallet
   home, etc. The new value clears 4.5:1 with margin (~6.2:1)
   and keeps a visible step between ink2 (8.4:1) and ink3.

2. `Onboarding.tsx` — `aria-hidden="true"` on the seed-grid
   index numbers ("1", "2", …, "12"). Those labels are purely
   decorative: a screen reader user hears each mnemonic word in
   order, no need to also announce its position. axe flagged them
   at 2.01:1 (ink4 on pure black), but the correct fix is to
   skip them from the accessibility tree, not to bump ink4
   globally and visually un-mute the disabled-state palette.

3. `receive/page.tsx` — pass `title="Receive address QR code"`
   to `QRCodeSVG`. qrcode.react's `title` prop is the
   documented accessibility hook and emits a `<title>` child of
   the SVG, which axe accepts as the accessible name for
   `svg-img-alt`.

Cleanup:

- `12-a11y.spec.ts` `KNOWN_VIOLATIONS` array now empty (all six
  entries removed) and the file header reflects the new state.
- `e2e/README.md` § 8.13 updated for the empty allowlist.

CI note: every screen that uses `text-ink3` will pixel-diff
against the existing visual baselines. The `E2E Tests` job will
go red until `regenerate-visual-baselines.yml` is dispatched on
this branch and its auto-PR is admin-merged into here.

* test(e2e): regenerate visual baselines (#91)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(a11y): keep KNOWN_VIOLATIONS populated until DEV picks up the fix

The accessibility spec runs against `dev.zkcoins.app`. DEV deploys
from `develop`, so a fix lives in two stages: code change merged →
deploy-dev workflow rolls → DEV serves the new bundle. Between the
merge and the deploy the spec would otherwise fire on "violations
the codebase no longer has" — green-vs-red flips on the deploy, not
on the merge.

Re-populate the allowlist with the six entries the spec surfaced
on the first run, but spell out in each reason that the code fix
already landed and the entry comes out in the follow-up PR after
the DEV deploy. Same wording in `e2e/README.md § 8.13`.

The next PR on this branch (once #83 has merged and the DEV
deploy is green) empties the array again.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…gic (#87)

* test(shell): cover AppShell + BottomNav prop matrix and active-tab logic

Adds src/__tests__/components/AppShell.test.tsx (11 tests).
AppShell wraps every wallet route and BottomNav dead-strips its
Apps tab via FEATURES.APPS_DIRECTORY; neither had a unit test, and
e2e only captured the styled default, not the prop matrix or the
active-tab logic across paths with sub-routes.

Covers:
- AppShell: renders children, shows nav + footer by default,
  omits each when showNav=false / showFooterLinks=false.
- BottomNav PRD bundle (APPS_DIRECTORY off):
  - "/" exact match highlights Wallet
  - non-root path does not highlight Wallet (no startsWith trap)
  - /settings exact + /settings/* sub-route both highlight Settings
  - nav-apps absent
- BottomNav DEV bundle (APPS_DIRECTORY on, via vi.resetModules):
  - nav-apps present with href="/apps"
  - /apps/* sub-route highlights Apps

* test(shell): use text marker instead of test-only testid

e2e/_audit/coverage.mjs walks the entire src/ tree, including
src/__tests__/, so a `data-testid="shell-child"` fixture in this
unit test was flagged as an uncovered MVP testid. Switch to a
text marker (`screen.getByText`) which avoids polluting the
src/ testid inventory.
On pass, the sticky PR comment now renders a single-line "all clear"
summary instead of dumping per-section lists of exempt entries and
template-literal resolutions. Reviewers reading the PR only need to know
whether the audit found something actionable; the implementation detail
of what counts as exempt belongs in the script, not in every comment.

On fail, the comment now lists only the actionable findings (non-exempt
A/C plus ghost B entries), so the punch list maps directly to what needs
fixing. --json output is unchanged and still emits all three sections
for tooling.
Adds src/__tests__/components/PwaPrompt.test.tsx (13 tests).
The component has four user-visible branches (native install,
iOS Safari, manual address-bar hint, hidden) plus a handful of
lifecycle invariants that e2e/10-pwa.spec.ts cannot verify:
listener cleanup on unmount, localStorage write/clear paths,
catch behaviour on thrown prompt().

Covers:
- visibility gates: dismissed flag in localStorage, standalone
  display-mode, iOS legacy `navigator.standalone`.
- mode detection: iOS Safari → ios card; Android UA without BIP →
  manual Android body; no UA hint → desktop manual body; BIP
  event switches manual → native card.
- native install flow: prompt + userChoice called; user
  rejecting flips the dismissed flag and unmounts the card; a
  thrown prompt() is swallowed and the button re-enables.
- dismiss + appinstalled: X-button persists the flag and unmounts;
  appinstalled clears the legacy hint flag; beforeinstallprompt
  listener is removed on unmount.
Closes the last gap from the test-coverage audit. Before this PR
the coverage gate only watched src/lib/** + src/stores/**, so any
new uncovered code in a page.tsx or component.tsx could ship
without triggering the threshold. The audit identified this as
the structural risk that lets app/components rot.

Changes:
- include now also covers src/app/** + src/components/**
- exclude expanded for non-MVP / decorative files:
  - layout.tsx (Next.js root, no logic)
  - app/apps, app/simulate, app/reset (FEATURES-gated → notFound)
  - app/network + components/NetworkActivity (network-activity
    chart, triage: keep — already excluded on the lib side)
  - components/PixelIcon, components/icons/** (decorative)
- thresholds split into two tiers:
  - src/lib/** + src/stores/**: still strict 100 % per-glob
    aggregate, matching the prior per-file invariant
  - Global aggregate: 75 % statements / functions / lines, 60 %
    branches — set just below the post-PR-coverage numbers so a
    regression on the UI surface fails CI

Depends on PRs #82, #84, #85, #86, #87, #89, #90 — without those
test additions the global aggregate is ~59 % and CI fails.
Merge those first, then this.
The first cleanup attempt (#93 v1) emptied `KNOWN_VIOLATIONS` straight
away, but the CI run against the freshly-deployed DEV surfaced three
residual color-contrast nodes that #83 didn't cover: the seed-grid
index labels ("1", "2", …, "12") inside `SeedFlow`. `aria-hidden`
is the correct hook for screen-readers, but axe's `color-contrast`
rule exempts only the *descendants of an ancestor with aria-hidden*,
not the element itself, so the labels still showed up at ink4@2.01:1.

This PR:
  - bumps the labels' Tailwind class from `text-ink4` to `text-ink3`
    (now ~6.2:1 on the dark surface, well over WCAG AA), keeping the
    `aria-hidden="true"` so screen readers continue to skip them;
  - trims `KNOWN_VIOLATIONS` to a single residual seed-reveal entry
    that disappears the moment the DEV deploy following this merge
    rolls out;
  - documents the post-deploy state in the spec header.

The trailing follow-up empties the array once DEV is live.
… 200 (#94)

The server-side fix (zk-coins/node#21) landed in develop, so the
/api/balance endpoint now returns 200 with `{balance: 0}` for any
well-formed but unobserved address. Drop the client-side try/catch
that mapped 404 to balance:0 — the contract is now uniform across the
client's `request()` helper.

- src/lib/api/client.ts: collapse `balance` to the same single-line
  shape as `info` and `resolveUsername`
- src/__tests__/lib/api/client.test.ts: replace the 404→0 mapping test
  with a 200→0 test that pins the new contract; keep the
  non-2xx-error case
- src/__tests__/lib/api/contract.live.test.ts: drop the stale 404
  reference from the comment
* fix(ui): submit forms on Enter key in send/onboarding/username-claim

* fix(ui): guard async handlers against concurrent triggers

Enter-key submissions could re-fire handlers while a previous call was
still in flight (claim, unlock, send), because button-disabled state is
not enforced by the keyboard path. Add explicit loading-state guards in
each async handler so click and keyboard paths behave identically.

Also realign the username-claim audit exemption snippet, which moved
out of the button window when the inline onClick was refactored to a
shared callback (so the keyDown path can reuse it).

* refactor(ui): migrate Enter-key handling to native form submission

Replace onKeyDown listeners and bare onClick submits with native HTML
<form onSubmit> semantics across send, onboarding (create + import +
unlock), and the username-claim mini-form on the wallet screen. The
browser now handles implicit submission (Enter on any single-line input
when the submit button is enabled), which gives us:

- Free race-condition protection (browsers do not implicitly submit
  while the type=submit button is disabled), so click and keyboard
  paths follow the same disabled gate.
- Better password-manager integration (form element is the contract
  managers look for to autofill + submit).
- Improved a11y (assistive tech recognises form regions).

Non-submit buttons (Set max, Cancel, Confirm Send) get an explicit
type=button so they no longer behave as default submitters inside the
new form, which would have re-triggered the Send flow.

The WalletScreen claim button gets its own data-testid now that the
exemption-by-snippet is no longer needed; the entry moves from
MVP_EXEMPT_BUTTON_SNIPPETS to MVP_EXEMPT_TESTIDS so future refactors
do not silently lose the exemption.

* cleanup(ui): drop race guards now redundant under form-submit semantics

After moving every keyboard submission path through native <form>
onSubmit, the browser already refuses to dispatch implicit submission
while the submit button is disabled, and disabled buttons swallow
clicks. The if (sending || ...) / if (unlocking) / if (claiming)
early-returns I added to defend the previous onKeyDown handlers no
longer fire and now just inflate useCallback dep arrays. Revert them
so each handler is back to its pre-PR shape (validation-only).

Also drop type="button" from the standalone passkey unlock button:
it has no enclosing form, so the attribute is inert today and is not
the style used by other standalone buttons in this codebase.
* Adapt to server 4xx/5xx + structured error contract

Add a typed `ApiError` class so non-2xx responses surface
`{status, serverError, rawBody}` instead of a stringly-typed
`Error("API error 4xx: ...")`. Centralise the server-error-string
→ German user-message mapping in `errorMessages.ts`, mirroring the
server's `map_send_coins_error` and `handler_error_response` tables.

Send-page and faucet call-sites now branch on `ApiError` to render
the user-facing message via `userMessageFor`. The send path also
normalises pre-PR-#31 servers (200 + `{success: false}`) into an
`ApiError` so the failure handling is uniform across both contracts.

Tests cover the round-trip for every known server error string and
assert each has a non-fallback mapping — fails loudly if the server
adds a new error string without an app-side mapping update.

Closes #99

* Exempt wallet-mint-error testid from button audit

The faucet error toast only renders on api.mint ApiError responses;
the happy-path E2E flow never triggers it. Coverage is provided by
the unit-level error-mapping tests.
The seed-grid index-label fix from #93 is now live on DEV
(deploy run 26024966020, 2026-05-18T09:26:11Z). Probed the
seed-reveal route directly — zero blocking axe violations
across the six MVP routes.

Drop the last allowlist entry and rewrite the spec header to
reflect the post-fix steady state. No code or DOM change here;
pure CI-gate tightening.
…102)

Replace the build-time NEXT_PUBLIC_ENABLE_FAUCET / NEXT_PUBLIC_ENABLE_USERNAMES
flags with a runtime fetch of /api/info.capabilities. The server reports its
Cargo feature set (faucet, usernames, address_list, lnurl) and the app gates
the corresponding UI via useFeatures() instead of mirroring the env at build
time. The same shipped bundle now works against any DEV/PRD server without
a rebuild.

- new useCapabilities zustand store with a fail-closed default so an
  unreachable server or a pre-#29 server hides gated UI rather than crashing
- InfoResponseSchema gains an optional CapabilitiesSchema for backward compat
- FEATURES retains the six build-time client flags (PASSKEY, APPS_DIRECTORY,
  AUTO_LOCK, ADDRESS_ROTATION, TOR_ROUTING, DEV_ROUTES); FAUCET / USERNAMES
  only live on useFeatures() and are not reachable via the static export
- mount the capabilities fetch from the root page useEffect
- migrate send/page.tsx and WalletScreen.tsx consumers to useFeatures()
- tests: new capabilities store coverage, features test split for the
  hybrid surface, existing FEATURES_STATE-mock pattern adapted to also
  back useFeatures(), api.info() schema test for both legacy and new
  response shapes
- README + Dockerfile + deploy-dev.yaml comments reflect the new model
…#104)

* fix(e2e): 07-send 'send-success' — bump timeouts + race against error banner

The send-success test failed twice in a row on the develop CI run
following PR #100's merge: `toBeVisible('send-success-heading')`
hit its 90 s gate before the 2-phase Send pipeline finished. The
PR #100 merge fired three deploys in 90 s and DEV was mid-roll
during the test window — proof generation slowed enough that a
single commit retry pushed the wallet UI past the 90 s gate.

Two small changes:

  1. `test.setTimeout(180_000)` and the locator timeout to 150 s
     — DEV's worst observed wall-clock for this flow is ~100 s
     (proof + commit + one retry); 150 s gives 50 s headroom and
     180 s on the test as a whole still surfaces a genuine deadlock.
  2. Race the success heading against `send-error`: either the
     happy path resolves or the inline error banner shows the real
     server-side failure. A future flake fails fast with the
     server's error text instead of "element never appeared
     after 150 s."

* test(home): cover Home routing decision tree

Adds src/__tests__/app/home.test.tsx (5 tests).
Home decides which of the three top-level screens render:
Onboarding (no stored wallet) → UnlockScreen (stored + locked) →
WalletScreen (account in memory). The branch priority is
load-bearing — a regression that swapped the order would
silently leave users on the wrong screen.

Covers:
- onboarding branch: both hydration calls find nothing → welcome
- unlock branch: encrypted blob in IndexedDB → unlock-heading
- wallet branch: account in store + unlocked → BottomNav + WalletScreen
- priority: account in memory overrides stored-blob-also-exists state
- priority: stored blob without account → unlock (not onboarding)
PR #105 was merged before its force-push update reached develop,
so the squash-merge picked up the pre-fix version of
`vi.mock('@/lib/features', ...)` that only exposed `FEATURES`.
After #102 (capabilities) landed, WalletScreen reads runtime
FAUCET/USERNAMES through `useFeatures()` — Home renders
WalletScreen on the unlocked branch, so this missing export
crashes 2 home tests + breaks the global Unit Tests job on
every other open PR that rebases onto develop.

Add the missing `useFeatures: () => FEATURES_STATE` proxy.
## Problem

When pushing multiple commits back-to-back to a PR, every commit
triggered a fresh CI run and the older runs kept running instead of
being cancelled. New commits queued behind doomed in-flight runs.

## Root cause

Both `ci.yaml` and `audit-button-coverage.yml` grouped concurrency by
commit SHA:

    group: ci-${{ github.event.pull_request.head.sha || github.sha }}

Every commit gets its own group, so `cancel-in-progress: true` never
triggered. The block's stated intent ("a new push cancels the in-flight
run") was the opposite of what actually happened.

## Fix

Group by PR number (with `github.ref` fallback for push/dispatch):

    group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}

A new push to the same PR now lands in the same group as the previous
run, so the old run gets cancelled and the new run starts immediately
instead of queueing.

`audit-button-coverage.yml` uses the same shape with its own workflow
prefix and a short pointer comment back to `ci.yaml` for the full
rationale.

## Stale trigger-block comment

The `on:` block in `ci.yaml` previously justified omitting `main` from
`pull_request.branches` with "the concurrency block would cancel one of
them and surface a red 'cancelled' check on the release PR". Under
PR-number grouping the PR-side run (keyed by PR number) and the push
run (keyed by `refs/heads/develop`) land in DIFFERENT groups, so the
block does NOT deduplicate them. The omission is still correct — it
avoids double-loading the same SHA — but the stale reasoning is
replaced so future readers do not reintroduce `main` by deleting the
comment.

## Concurrency-block gaps on deploy / regen workflows

This change also closes adjacent concurrency-block gaps on the deploy
and regen workflows. They are independent improvements bundled here
because they touch the same area:

- `deploy-dev.yaml`: `deploy-dev-${{ github.ref }}` with
  `cancel-in-progress: true`. Back-to-back develop pushes used to fire
  parallel deploys that raced on `docker compose recreate`; the newest
  commit's deploy now wins and the older one is cancelled. A
  `workflow_dispatch` from a feature branch lands in its own ref-keyed
  group and does not interact with the develop deploy.
- `deploy-prd.yaml`: `deploy-prd-${{ github.ref }}` with
  `cancel-in-progress: false`. Production deploys must NEVER be killed
  mid-flight — cancelling halfway through `docker compose recreate`
  produces half-renamed Created-state containers; subsequent deploys
  queue instead of preempting.
- `regenerate-visual-baselines.yml`: keyed by `inputs.branch` with
  `cancel-in-progress: false`. Two concurrent runs against the same
  branch would race on the auto-commit of the new baselines and clobber
  each other.

## Behaviour

| Event                             | Group                                                              | Effect                                                            |
|-----------------------------------|--------------------------------------------------------------------|-------------------------------------------------------------------|
| 2nd push to PR #42                | `ci-CI-42`                                                         | cancels in-flight run for #42, starts fresh run                   |
| Post-merge `push: develop`        | `ci-CI-refs/heads/develop`                                         | own group, doesn't touch open PRs                                 |
| `workflow_dispatch` on a feature  | `ci-CI-refs/heads/<feature>`                                       | own group per ref                                                 |
| 2nd push to develop (DEV deploy)  | `deploy-dev-refs/heads/develop`                                    | newest commit's deploy wins; older one cancelled                  |
| 2nd push to main (PRD deploy)     | `deploy-prd-refs/heads/main`                                       | queued; never preempt mid-flight                                  |
| Regen run for branch `feat/x`     | `regenerate-visual-baselines-feat/x`                               | queued; independent branches run in parallel                      |

## Test plan

- [ ] Push a second commit to this PR; first CI run gets cancelled, second runs without queueing.
- [ ] After merge: the `push: develop` CI run fires independently of any open PR and gets its own group.
- [ ] Two back-to-back merges to develop: only the newest commit's DEV deploy runs to completion.
Aligns the Docker Hub namespace with the rest of the zkCoins project
identity (GitHub org `zk-coins`, domain `zkcoins.app`, sibling repo
`zk-coins/node` already on `zkcoins/node`). The singular `zkcoin/*`
namespace was a holdover from the early days.

Touched:
- .github/workflows/deploy-dev.yaml — DOCKER_TAGS: zkcoin/app:beta → zkcoins/app:beta
- .github/workflows/deploy-prd.yaml — DOCKER_TAGS: zkcoin/app:latest → zkcoins/app:latest
- README.md, CONTRIBUTING.md — image refs in tables + docker build examples

Repo secrets DOCKER_USERNAME / DOCKER_PASSWORD already swapped to the
new `zkcoins` Docker Hub account (same PAT used by zk-coins/node).

Coordinated DFXServer/server compose update follows immediately after
merge so dfxdev / dfxprd pull `zkcoins/app:{beta,latest}`.
TaprootFreak and others added 5 commits May 25, 2026 09:16
- Docker version + pulls badges at the top
- Explicit hub.docker.com/r/zkcoins/app link in lead paragraph
- Image-tag cells in the Live table now link directly to the
  corresponding Docker Hub tag listing
* docs: update zk-coins/server links to zk-coins/node

Post-rename audit found 3 markdown refs in README.md + 1 in
CONTRIBUTING.md still pointing at the legacy zk-coins/server GitHub
URL. GitHub auto-redirects so functionality is unaffected, but the
cosmetic drift was flagged by both internal- and external-consistency
audits. Replaces the literal repo string everywhere; preserves the
historical issue numbers (those redirect cleanly).

* fix: residual server → node refs in code comments + e2e doc

Second-pass audit found cross-repo references the first PR missed:

- e2e/README.md: cross-repo doc pointer "zk-coins/server CONTRIBUTING.md
  § DEV state recovery" → zk-coins/node
- src/stores/capabilities.ts, src/lib/api/{errorMessages,schemas}.ts,
  src/lib/features.ts, src/__tests__/lib/api/error-mapping.test.ts:
  source comments referencing the old Rust module locations
  (`zk-coins/server::server.rs::Capabilities`, ...::map_send_coins_error)
  switched to `zk-coins/node::router.rs::...`

Comment-only changes; no runtime / test behavior touched.
)

Document the rule that surfaced from the May 2026 `07-send-success`
incident: the App is a thin client. The private key + UI rendering
live in the App; everything else (balance, num_sends, transaction
history, server capabilities, account proofs, commitment lookups)
lives on `zk-coins/node` and the App must fetch the authoritative
value before every operation that depends on it.

The local-store `numPubkeys` counter that produced the
`prove_account_update_with_in_and_out_coins_and_sources failed`
class of bugs is called out explicitly so the next maintainer
doesn't repeat the assumption that App-side derivation is "free".

Update the State Management subsection to point at the new rule
and trim the list of authoritative store fields to those that
genuinely belong in the App (xpriv, address, UI flags) instead of
the previously-listed `transactions` (server-owned).
The wallet tracks the BIP-32 child-index counter (`numPubkeys`)
purely in local state. After a seed restore the counter is reset
to 0 — but the server may still hold `account.proof = Some(...)`
from a previous session for the same address. The next /send then
either omits `prev_commitment_pubkey` (server 400
`"prev_commitment_pubkey required for account update"`, mapped
client-side to `Interner Fehler: Vorheriger Public Key fehlt.`)
or reuses pubkey[0] and collides on the same SMT slot at commit
time. The E2E `07-send.spec.ts::send-success` test exposed this
on every retry after the first global-setup minted balance was
spent.

This is the app side of zk-coins/node PR #129, which adds the
authoritative `Account.num_sends` counter and emits it on
`/api/balance`. The app:

* extends `BalanceResponseSchema` with `num_sends: z.number().default(0)`;
* adds `syncNumPubkeys(n)` to the wallet store — a no-op fast-path
  when the counter is already correct, replace semantics otherwise;
* drives a fresh `api.balance` BEFORE every send (SendPage) so the
  counter is hydrated against the server's source of truth even
  if the WalletScreen 5-s poll hasn't ticked yet;
* hydrates `numPubkeys` on every seed/passkey restore flow;
* re-syncs on every WalletScreen balance tick + after the post-send
  /api/balance refresh.

Tests:
* `wallet.test.ts` — `syncNumPubkeys` replace semantics + no-op
  shortcut + multi-account safety.
* `send-pipeline.test.tsx` — pre-send balance hydration is mocked
  in every send-confirm path; the existing `prev_commitment_pubkey`
  + `numPubkeys` assertions still hold.

App-PR depends on node-PR being merged + DEV-deployed first —
otherwise the wallet reads `undefined` for `num_sends` against the
old server. The schema's `.default(0)` keeps the fallback safe
(local counter behaviour preserved).
…es + fresh Goldens (#129)

* ci(regen): add spec_glob + tolerate_failures inputs to baseline regen

Currently the workflow runs every spec under e2e/0*.spec.ts e2e/1*.spec.ts
unconditionally and the whole job aborts (set -e) the moment any spec
fails. When the only thing that needs regenerating is the wallet
header — and an unrelated spec (e.g. 07-send) is flaky on DEV — the
regen can't even reach its commit step. The fix that needs the
baselines gets stuck behind a problem it has nothing to do with.

Two new workflow_dispatch inputs:

- `spec_glob` (default unchanged) — narrows the run to specific
  files. e.g. `e2e/02-create-seed.spec.ts e2e/03-restore-seed.spec.ts
  e2e/04-unlock-password.spec.ts` only refreshes the wallet headers.
- `tolerate_failures` (default false, opt-in) — appends `|| true` to
  the playwright invocation. `--update-snapshots=all` still writes
  new PNGs for the specs that ran; the commit step inspects
  `git status` and commits only files that actually changed.
  Partial regens are safe.

Defaults preserve current behaviour — full suite, fail-fast — so
existing routine calls keep working.

* fix(errorMessages): family pattern matching for diagnostic node error strings

The node API emits diagnostic, field-naming error strings
(e.g. "account_address is not valid hex",
"recipient must be 32 bytes (64 hex chars)",
"Failed to broadcast commitment inscription on-chain") that did not
match the exact-string lookup table on the app side, so users saw the
raw "Serverfehler <status>: ..." fallback instead of the translated
message.

Adds a second lookup layer SERVER_ERROR_PATTERNS (regex) that runs only
when the existing exact-match pass misses. KNOWN_SERVER_ERRORS stays
unchanged so the lockstep test with
api_remote.rs::error_strings_match_known_app_mapping keeps holding.

Patterns are ordered specific-before-generic and intentionally do not
overlap with any KNOWN_SERVER_ERRORS string — exact-match always wins.

Three new test cases in error-mapping.test.ts cover one variant per
pattern, including an address-length variant with a different field
name to prove the family (not a specific field) is matched.

* fix(wallet): case-insensitive network check for faucet gating

The node reports `network` as a display string ("Mainnet", "Mutinynet",
…) — see `node::router::info_handler`. The existing check used a
case-sensitive comparison, so `"Mainnet" !== "mainnet"` evaluated true
and the faucet button rendered on Production. Lowercase the network
name before the comparison so a casing decision on the server can't
accidentally re-enable mint on Mainnet.

Add a WalletScreen unit test for both branches (Mainnet hidden,
Mutinynet visible). Update the `06-balance-zero-empty-banner` e2e
spec to reflect testnet reality (faucet button is the primary CTA
in the empty-wallet banner on Mutinynet); the Mainnet-hidden case
is covered at the unit-test layer since E2E always runs against
the testnet API.

* test(e2e): regenerate visual baselines

* test(e2e): raise send-success timeout to account for post-#127/#129 latency

The send-flow now does an extra `/api/balance` round-trip before
signing (`num_sends` hydration per the thin-client rule in
CONTRIBUTING.md), and the matching server-side writes from
zk-coins/node #129 / #132 add a few seconds of legitimate per-send
latency for the atomic `Account.num_sends` + `commitment_public_key`
upsert. Observed wall-time on the bundle PR's CI runs is 180-220 s,
which the previous 180 s test cap was treating as a deadlock.

Lift the waitFor cap from 150 s to 250 s and the overall test cap
from 180 s to 300 s. The race against the inline error banner stays
in place so a genuine server-side failure still surfaces with its
real message instead of timing out.

* test(e2e): raise send-success cap to 480 s for Mutinynet jitter + parallel-load

The 300 s cap from the previous commit was still insufficient on the
bundle PR's CI. Empirically the send-success spec lands between
180 s and ~5 min on DEV. The variance is driven by:

  * Publisher track-tx WS jitter — 30 s timeout per attempt, up to
    three attempts under load when Mutinynet block-event delivery
    stalls (see `node/src/publisher.rs::TRACK_TX_TIMEOUT_SECS`).
  * Playwright `fullyParallel: true` saturates the shared DEV node
    with mint + balance traffic from neighbour specs while
    07-send-success is its only real-send test, but everything else
    keeps hammering /api/balance, /api/info, and (via globalSetup)
    /api/mint — node mutex contention pushes the lone send past
    its happy path.

Raise the spec cap to 480 s and the waitFor cap to 420 s. Deadlock
still surfaces; well-behaved-but-slow runs no longer mask as a
regression. Follow-ups (out of scope for this PR): (a) tighten the
publisher's track-tx budget, (b) consider serializing the
mutation-heavy specs against the DEV node.

* test(e2e): harden globalSetup against cold-start navigation timeouts

The CI runner's first page.goto('/') after boot occasionally exceeds
Playwright's default 30 s navigation timeout — Cloudflare cold path,
Next.js standalone first-paint, and the WASM bundle handshake all
stack on the very first request. Subsequent navigations in the same
context are fast.

- Raise the context-wide navigation timeout to 90 s for both wallet-
  bootstrap contexts. Scoped to globalSetup only so test specs keep
  the 30 s default and a genuine app-side regression still surfaces
  as a timeout.
- Wrap each wallet bootstrap (clearWalletState + createSeedWallet) in
  a single retry on a fresh page so a transient first-paint timeout
  doesn't fail the whole CI run.
- Fix a TS strictness regression in 07-send.spec.ts where
  textContent() returns string | null and was passed directly to
  expect's message slot (string | undefined).

* test(e2e): serialize 07-send + raise send-success cap to 12 min

The 480 s cap introduced in 3abecfa was still consistently hit by the
real Send through DEV under the suite's parallel load. Both attempts
(test + retry) timed out, with the waitFor(420 s) draining before the
test cap.

Empirically the DEV Send takes 180 s to ~7 min depending on Mutinynet
block-time jitter plus neighbouring specs hitting the node. Raising
the cap is one half of the calibration; the other half is reducing
load:

-  on the file keeps the
  13 in-file tests in a single worker so two 07-send tests can no
  longer fight each other for DEV bandwidth.
-  (12 min) and the  waitFor
  budgets raised to 660 s (11 min). Setup overhead (login + balance
  hydration) lives in the remaining 60 s of headroom.

A genuine deadlock still surfaces in 11 min instead of 7. The
follow-up to tighten publisher track-tx is unchanged.

* ci(e2e): give send-success exclusive DEV-node bandwidth via two-step job

Three consecutive E2E runs failed on `07-send-success` at progressively
larger test-timeout caps (180 s -> 300 s -> 480 s -> 720 s). Every time
the DEV node's single proof-gen pipeline was starved by parallel mint
and balance traffic from the rest of the suite (09 / 10 / 11), pushing
the real Send wall-clock above the cap.

Increasing the cap further is the wrong direction. Structural fix:
split the existing E2E step in two.

  Step 1: `npx playwright test --grep-invert "send-success"` -- the
  parallel-safe bulk of the suite (75 specs) running with the default
  fullyParallel + 2 workers.

  Step 2: `npx playwright test --grep "send-success" --workers=1` --
  the one mutating Send test with exclusive DEV-node bandwidth, no
  competing proof-gen / balance traffic from neighbouring specs.

Side fixes:
- Drop `test.describe.configure({ mode: 'serial' })` from the spec.
  Serial mode caused a cascade-retry failure where send-no-funds-banner
  re-ran on the group retry and failed for an unrelated reason.
- Lower send-success cap back to 6 min (test) + 5 min (waitFor) since
  exclusive bandwidth drops the realistic upper bound to ~3-4 min.

globalSetup runs once per playwright invocation, so each step gets a
clean fresh-Alice + fresh-Bob fixture set independent of the other.

* fix(e2e): guard error.textContent() behind race-winner check

ROOT CAUSE of all `07-send-success` failures since PR #104.

The test races the success heading against the inline error banner via
`Promise.race`, then resolves both:

  await error.textContent().catch(() => null)

The catch handles a *rejected* promise, but Playwright's
`Locator.textContent()` does not reject when no element matches —
it WAITS for the element to appear, blocking up to the test-timeout.
On the success page (the happy path) `send-error` is not in the DOM,
so textContent never resolves and the assertion at line 216 sat
spinning until the 360 s test cap fired, even though the success
heading had rendered minutes earlier.

Failure-screenshot evidence from run 26651174055: the page clearly
shows "Sent privately" with proof #183 / #184 in both attempt 1 and
the retry. The Send succeeded server-side; only the post-race
assertion blocked.

Fix: tag which branch of the race won, then act only on that branch.
The error branch resolves textContent (which is safe because the
locator just became visible). The heading branch skips the error
check entirely. This was the intent of the race-against-error pattern
from the start — the implementation just resolved the error too
eagerly.

Side-effect: with this fix the previous timeout-budget escalations
(180 s -> 300 s -> 480 s -> 720 s -> 360 s) were all chasing the
wrong cause. The 360 s cap stays — it's a reasonable upper bound
for a real DEV-node Send, but the test now resolves as soon as
the success heading appears.

* test(e2e): refresh 07-send goldens for post-textContent-fix flow

The 07-send-success golden was generated against the broken textContent
hang (test never reached the snap() call, so no PNG was ever captured).
With the race-winner guard in a15cc24 the test now actually takes a
fresh screenshot of the success page and compares it to the on-branch
golden, which had drifted with the post-2026-05-23 header/balance
layout pass.

Includes three other 07-send goldens that the same regen run picked
up (confirm-dialog desktop/mobile + recipient-valid-hex) — the
maxDiffPixelRatio: 0.01 tolerance had been masking the same drift
under the threshold; refreshing them now keeps the diffs at 0 px
for any future regression that crosses the threshold.

Regen run: 26652625153 against chore/all-app-fixes-bundle.

* fix: address subagent review findings

MAJOR (security):
- regenerate-visual-baselines.yml — command injection via
  `${{ inputs.spec_glob }}` interpolated into a shell string and
  `eval`'d. Replaced with env-var passthrough + array-form CMD,
  no eval. Glob expansion still works ($SPEC_GLOB unquoted), but
  user-controlled metachars are now data, not parser tokens.
  CWE-78.

MINOR:
- errorMessages.ts — doc claim "patterns must NOT overlap with
  KNOWN_SERVER_ERRORS" was wrong (overlap exists today, exact-first
  shadows it harmlessly). Rewrote the comment to reflect reality
  and added the test that the comment now references:
  `every KNOWN_SERVER_ERRORS string resolves via the exact-match
  table, not via a pattern`. Catches a future refactor that
  deletes an exact entry and lets a pattern silently swap the
  German copy. Also exported SERVER_ERROR_PATTERNS +
  SERVER_ERROR_TO_USER_MESSAGE so the test can introspect them.

- _global-setup.ts::withPageRetry — page leaked on the success
  path (no close after `return await step(page)`) and on the
  terminal throw. Moved close into a `finally` block.

- ci.yaml::e2e-tests — added `timeout-minutes: 30` so a hung
  browser process can't sit on the default 6 h budget.

* fix(errorMessages): trim dead-code assertion + revert unused export

Subagent re-review NITs:
- error-mapping.test.ts had a tautological inner assertion
  (userMessageFor always returns the exact entry first, so the
  conditional check could never fire). Line 76's .toBeDefined()
  already enforces the real lockstep invariant — trim the rest.
- SERVER_ERROR_PATTERNS no longer needs export now that the test
  doesn't consult it. Reverted to module-private.
- SERVER_ERROR_TO_USER_MESSAGE stays exported (the test needs it),
  marked @internal so production callers stick to userMessageFor().

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
@TaprootFreak
TaprootFreak merged commit e33e7a6 into main May 29, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant