Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/wiki/domains/e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,49 @@ lifecycle depth, and gaps live in `e2e/TEST_MAP.md`. `e2e/CLAUDE.md`
a foreign server can't bypass the pinned env (`VITE_E2E` keeps production
form validation ON, so zod bounds ARE assertable in specs).

## Live API mode (opt-in, separate project)

The offline suite is the acceptance suite; live mode is a validation suite for
Register's *usage* of the Reserve and zapper APIs, in its own `live` Playwright
project (`pnpm e2e:live`, `@live` grep, 1 worker, no retries) that the smoke,
full, and mobile projects exclude. It is off unless an env var names a target:
`E2E_LIVE_RESERVE_API` and `E2E_LIVE_ZAPPER_API`, resolved independently by
`helpers/live.ts` (`production` / `staging` / `zrs1` / an absolute URL; an
unknown value throws rather than silently falling back offline).
Comment on lines +64 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Specify that custom targets are origins.

resolveLiveTarget in e2e/helpers/live.ts stores new URL(raw).origin. Therefore, https://host/prefix silently loses /prefix. Either preserve the path in the helper or document and reject path-prefixed values. Apply the same rule in e2e/README.md Lines 78-84.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/wiki/domains/e2e.md` around lines 64 - 66, Update the documentation for
E2E_LIVE_RESERVE_API and E2E_LIVE_ZAPPER_API in docs/wiki/domains/e2e.md and
e2e/README.md to state that custom absolute URL targets must be origins without
path prefixes. Make clear that values such as https://host/prefix are not
supported because resolveLiveTarget stores only new URL(raw).origin, and
document the expected accepted format consistently in both locations.


Design decisions worth keeping:

- **One surface classifier, not a URL soup.** `surfaceForPath` owns the split —
`/api/zapper/**` and `/api/prices/**` are the zapper surface, everything else
is Reserve — so a planner deployment (zrs1) and api.reserve.org can be
validated in the same run.
- **Live is a boundary swap, not an escape hatch.** `helpers/api.ts` consults the
live target *before* snapshots and overrides; everything else (default-deny
egress, `boundaryRequests` recording, teardown failure) is unchanged, and
`allowUnmocked` is still not a live toggle. Live responses are additionally
validated by `helpers/live-contracts.ts` — per-endpoint zod shapes plus
invariants (quote `minAmountOut <= amountOut`, non-zero `amountOut`, a `tx`
whenever funds suffice, candle `high >= low` and open/close within range).
Shape drift or a broken invariant fails the test.
- **Everything non-API stays mocked**: RPC, subgraph, wallet, receipts, chain
state, and the CoW/velora/enso aggregators (explicit disabled 503 — the
planner zap is the provider under validation). So live UI specs are *hybrid*:
live API data joined to pinned chain state. When the live basket drifts from
the captured one the SDK can't join them, so `basketDrift` detects it and the
spec skips with a re-capture instruction instead of reporting a fake failure
or a fake pass.
- **Bounded probing.** `/api/zapper/{chain}/tokens` is ~500 MB; `liveProbe`
reads a prefix and validates on that, never buffering the response.
- **Teardown races are not violations.** `isTeardownRace` filters only
browser/page-closed messages from in-flight passthrough fetches; real
transport failures and timeouts remain contract violations.

Coverage lives in `e2e/tests/live/` (contract specs for the Reserve and planner
surfaces, plus live pricing and zap-widget UI specs); `e2e/README.md` § Live API
mode is the operator guide, and the standing findings/limitations (token-list
parser drift, planner price gaps, hybrid-basket drift, deploy being quote-level
only) are in [[progress]] § E2E coverage debt.

## Transactions and time

The injected EIP-6963/EIP-1193 provider records every send in the per-test
Expand Down Expand Up @@ -89,6 +132,8 @@ age, and DTF/chain identity.
- `pnpm e2e:check`: manifest, identity, and freshness.
- `pnpm e2e:capture:yield`: re-capture the yield RToken eth_call + subgraph maps.
- `pnpm exec vitest run e2e/helpers/tests`: mock-contract unit tests.
- `E2E_LIVE_ZAPPER_API=zrs1 pnpm e2e:live`: the opt-in live API validation
project (never runs in CI; needs egress to the target).

CI uses pnpm, Node 24, current actions, and Chromium only. PR/push runs
typecheck, the mock-contract unit tests, snapshot check, and smoke; nightly/
Expand Down
27 changes: 26 additions & 1 deletion docs/wiki/progress.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Progress
updated: 2026-08-10
updated: 2026-08-11
type: ledger
---

Expand All @@ -10,6 +10,7 @@ Stage ledger. One row per stage; keep entries short. Verifier = exact fresh comm

| Stage | Status | Verifier | Review | Next |
|---|---|---|---|---|
| Live API validation mode for the E2E suite (reserve + zapper/planner, zrs1) | human-review-required (base a6f20e340) | offline unchanged: typecheck · lint · 95 helper units (23 new) · smoke 58 · `e2e:check` · `E2E_LIVE_RESERVE_API=production E2E_LIVE_ZAPPER_API=zrs1 pnpm e2e:live` → 39 passed / 1 skipped (CMC20 basket drift) | self: default-deny, request recording and strict teardown unchanged; no `allowUnmocked`, no wildcard mocks; findings recorded, not asserted away | **Engineer review required**: deploy is quote-level only; token-list drift needs a client-or-API decision — § E2E coverage debt |
| Preserve Index DTF section in cmd-k navigation | human-review-required (base ebcd6febe) | RED: proposal/rebalance both landed overview; GREEN 2/2 · gate typecheck/lint/880 unit · live Ctrl+K proposal→governance + rebalance→auctions | independent Intent + Engineering Risk PASS, no findings; shared route-selection behavior requires Engineer review | merge only after Engineer review; wiki-lint blocked by pre-existing stale design-system page |
| vote modal: address-length title overflowed the dialog | done (base 6854a370b) | lint · typecheck · test:run · e2e helper units · smoke 58 · new `vote-modal-long-title` spec desktop+mobile green · RED-verified (reverted the index-dtf modal fix → checkbox right edge 943 vs dialog 849.9) | product/correctness: self — copy + layout only, no tx path touched | — |
| Fix DTF settings confirm button | human-review-required (base 6854a370b) | RED: rounded seeded distribution blocked mandate confirm; GREEN: unit 878 incl. mapper 27/27 · focused E2E 5/5 · typecheck · lint | Dark HOLD on untested mapping → 27 exhaustive mapper tests → Dark PASS; Light PASS; CodeRabbit 2 Minor → resolved (test IDs + editable confirmed state) | PR #1084 open; Engineer review required before merge; wiki-lint blocked by pre-existing stale design-system page |
Expand Down Expand Up @@ -46,6 +47,30 @@ Stage ledger. One row per stage; keep entries short. Verifier = exact fresh comm
### E2E coverage debt (fail-loud workarounds to pay down)

- **Index/Arbitrum egress assertion owed**: a spec inspecting `boundaryRequests` asserting NO Index-domain call carries chainId 42161 (with a Yield-positive counterpart — dtf-yield keeps Arbitrum). A green smoke does NOT prove this: teardown only fails on unmocked calls and the RPC mock answers Arbitrum generically.
- **Live API mode findings + limits** (`pnpm e2e:live`, 2026-08-11 against
reserve=production, zapper=zrs1 — 39 passed / 1 skipped):
- **`fetchZapperTokens` cannot read any deployment's token list** (`src/utils/zapper.ts`):
it reads `data.tokens[]`, every deployment answers `{status, result[]}`, and
the helper's `catch` turns the mismatch into an empty Set — "no token is
zappable", silently. Pinned with `test.fail()` in
`tests/live/zapper-api-contract.spec.ts`. Fix = parse `result[]` (or align the
API) and drop the pin; needs a decision on which side moves.
- **The planner prices no PHOTON Ondo RWA token on BSC** (`/api/prices/56`
returns `result: []` for 9 basket tokens; the route doesn't exist on
api.reserve.org at all). Not a register bug today — register reads
`/current/prices`, which covers them — so the spec asserts exactly that
cross-surface guarantee: a planner gap must be covered by the Reserve API,
else the basket renders $0.
- **Live UI specs are hybrid** (live API + pinned chain state), so a real
basket change invalidates them: CMC20 skips because the live basket added
`0x2859e4544c4bb03966803b044a93563bd2d0dd4d` and the SDK can't join the two.
Pay down by re-capturing (`pnpm e2e:capture`) or by making the live UI specs
seed chain state from the live basket.
- **Deploy is quote-level only** — the planner's deploy/deploy-ungoverned tx is
validated as a contract, never submitted; on-chain deploy stays uncovered.
- **Aggregators are deliberately disabled in live mode** (CoW/velora/enso get an
explicit 503) so the planner is the provider under validation; live CoW
routing is therefore uncovered.
- **Vote-lock (vlRSR) uncovered paths** (Codex review 2026-07-31): external Earn/portfolio drawer first-open (the hook-order crash path — fixed, untested); portfolio live-zero + RPC-error fallback branches; governance card rate/redeemable presentation; a legacy 1:1 vault through the universal redeem path (drawer spec only covers vlRSR); earn cell RPC-failure fallback (skeleton→1:1).

A mock strict enough that a spec routes AROUND its gap silently shrinks the
Expand Down
2 changes: 2 additions & 0 deletions e2e/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ Then follow the steps below to fill it in.
## Commands

`pnpm e2e:smoke` (fast, per-diff) · `pnpm e2e:full` (flows) · `pnpm e2e` (both)
· `E2E_LIVE_ZAPPER_API=zrs1 pnpm e2e:live` (opt-in live API validation — the
`@live` project, never part of the offline suites; `e2e/README.md` § Live API mode)
· `pnpm e2e:ui` (headed debug) · `pnpm exec vitest run e2e/helpers/tests`
(mock contracts, fastest tier) · `pnpm e2e:check` / `pnpm e2e:capture` /
`pnpm e2e:capture:yield` (snapshots).
Expand Down
56 changes: 56 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,62 @@ default-denies any non-local request that is not explicitly modeled:
shared by mocks, capture, and tests. `helpers/snapshots.ts` loads the
`{_meta, data}` envelope from `snapshots/<chain>/<slug>/*.json`.

## Live API mode (validating the real Reserve / zapper APIs)

The committed suite is offline and stays that way. Live mode is a separate
opt-in project (`pnpm e2e:live`, `@live`-tagged specs under `tests/live/`,
excluded from smoke/full/mobile) that swaps ONLY the API boundary for a
recording passthrough:

```bash
E2E_LIVE_ZAPPER_API=zrs1 pnpm e2e:live # planner surface only
E2E_LIVE_RESERVE_API=production E2E_LIVE_ZAPPER_API=zrs1 pnpm e2e:live # both
```

The two surfaces are independent env vars, each taking a named target or an
absolute URL (anything else is a hard error, not a silent offline fallback):

| Var | Targets | Surface (`surfaceForPath`) |
|---|---|---|
| `E2E_LIVE_RESERVE_API` | `production` (api.reserve.org), `staging`, `https://…` | everything except the two paths below |
| `E2E_LIVE_ZAPPER_API` | `zrs1` (zrs-1.reserve-api.com), `production`, `staging`, `https://…` | `/api/zapper/**`, `/api/prices/**` |

What live mode does and does not touch:

- **Live**: the configured surface's HTTP responses, rewritten onto the target
origin, recorded in `boundaryRequests` exactly like a mocked call, and
validated against `helpers/live-contracts.ts` (zod shape + invariants:
`minAmountOut <= amountOut`, non-zero `amountOut`, a `tx` whenever funds are
sufficient, candle `high >= low`, open/close inside high/low). Drift fails the
test; the unmocked-egress default-deny is still in force.
- **Still mocked, always**: RPC, subgraph, wallet, receipts, chain state, and
the CoW/velora/enso aggregators (an explicit disabled 503, so the planner zap
is the provider under validation). Chain state therefore stays pinned to the
committed snapshots — see the hybrid-test limitation below.

Coverage: `tests/live/reserve-api-contract.spec.ts` and
`zapper-api-contract.spec.ts` are request-level (no browser) and cover prices,
current/historical DTF, candles, compliance, discover, portfolio, exposure,
rebalance + liquidity, the zappable token lists, planner health, buy quotes and
the ungoverned deploy quote. `pricing-live.spec.ts` and `zap-widget-live.spec.ts`
drive the real UI against live responses.

Limitations (all deliberate, tracked in `docs/wiki/progress.md` § E2E coverage debt):

- **Hybrid tests can be invalidated by real basket changes.** The UI specs join
live API data with pinned chain state; when the live basket gains or loses a
token the SDK cannot join the two and the spec skips with a `basketDrift`
message telling you to run `pnpm e2e:capture`. It reports drift, it does not
paper over it.
- **`/api/zapper/{chain}/tokens` is ~500 MB.** It is probed in bounded form
(`liveProbe`), never buffered.
- **The token list is pinned as known drift**: `fetchZapperTokens` reads
`data.tokens[]`, every deployment answers `{status, result[]}`, and the
helper's catch-all turns that into "nothing is zappable" with no error. The
spec asserts the mismatch with `test.fail()`.
- **Deploy is quote-level only.** On-chain deployment is not executed; the
deploy transaction the planner returns is not submitted to a real chain.

## Fail-loud philosophy

Every mock that can't answer calls the logger (`[E2E] unmocked ...`). The base
Expand Down
21 changes: 19 additions & 2 deletions e2e/TEST_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ grep -rln "@mobile" e2e/tests # mobile-tagged specs

Harness architecture (mock layer, trust contract, CI split) lives in
`docs/wiki/domains/e2e.md`; mock mechanics and recipes live in `e2e/CLAUDE.md`.
Playwright runs 3 projects (`playwright.config.ts`): **smoke** (`@smoke`-tagged,
Playwright runs 4 projects (`playwright.config.ts`): **smoke** (`@smoke`-tagged,
Desktop Chrome), **full** (everything else, Desktop Chrome), **mobile**
(`@mobile`-tagged, Pixel 7 viewport — off CI, `pnpm e2e:mobile`).
(`@mobile`-tagged, Pixel 7 viewport — off CI, `pnpm e2e:mobile`), and **live**
(`@live`-tagged, opt-in, excluded from the other three — see § Live API
validation).
Comment on lines +14 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Qualify the inventory as offline-only or update its counts.

The new tests/live/ specs add a sixth top-level directory, but the inventory below still says 73 specs across 5 top-level dirs. Change that sentence to identify the offline suite, or update the counts to include the live specs.

As per coding guidelines: “When documentation is created or invalidated, delete or merge superseded documentation and update stale claims during the same task.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/TEST_MAP.md` around lines 14 - 18, Update the inventory statement in
e2e/TEST_MAP.md to clarify that the 73-spec, five-directory count applies only
to the offline suite, or revise the counts to include tests/live/. Keep the
documented Playwright project breakdown consistent with the chosen scope.

Source: Coding guidelines


73 specs across 5 top-level dirs: `general/` (8), `index-dtf/` (17),
`yield-dtf/` (6), `smoke/` (12), `flows/` (30). `index-dtf/` and `yield-dtf/`
Expand Down Expand Up @@ -97,6 +99,21 @@ additional state coverage: [boot](tests/smoke/boot.spec.ts) (home shell),
auction/issuance write and edge-case behavior) and all of `smoke/` have zero
`@mobile` coverage.

## Live API validation (`tests/live/`, opt-in)

Off by default; enabled per surface with `E2E_LIVE_RESERVE_API` /
`E2E_LIVE_ZAPPER_API` and run with `pnpm e2e:live`. Not part of the offline
coverage matrix above — these specs validate Register's API *usage* against a
real deployment (zrs1 for the planner) and are excluded from smoke/full/mobile.
Operator guide: `e2e/README.md` § Live API mode.

| Surface | Spec | Covers | Not covered |
|---|---|---|---|
| Reserve API (request-level) | [live/reserve-api-contract](tests/live/reserve-api-contract.spec.ts) | `/current/prices`, `/current/dtf`, `/historical/prices`, `/historical/dtf` (+ v2 candles), compliance (geolocation, per-DTF, wallet), `/v1/discover/dtfs`, `/v1/portfolio/:address`, `/dtf/exposure`, `/dtf/rebalance`, `POST /rebalance/liquidity`, legacy `/zapper/tokens` | auth'd surfaces, write endpoints |
| Zapper/planner (request-level) | [live/zapper-api-contract](tests/live/zapper-api-contract.spec.ts) | planner `health`, bounded `/api/zapper/{chain}/tokens` probe per chain, `/api/prices/{chain}` per basket (+ reserve-covers-the-gap cross-check), buy quotes per DTF, ungoverned deploy quote | sell quotes, governed deploy body variants, on-chain execution |
| Pricing UI | [live/pricing-live](tests/live/pricing-live.spec.ts) | overview hero price + plotted chart geometry from live Reserve responses per registry DTF | any DTF whose live basket drifted from the captured chain state (skips with a re-capture instruction) |
| Zap widget UI | [live/zap-widget-live](tests/live/zap-widget-live.spec.ts) | buy quote through the real widget on Base, submitted tx equals the live quote's `tx` (mocked wallet/receipts) | sell, other chains, real chain execution |

## Active fixmes (2)

- `tests/index-dtf/overview/edge-cases.spec.ts:22` — Market Cap data-type
Expand Down
49 changes: 48 additions & 1 deletion e2e/fixtures/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DEFAULT_GEOLOCATION, mockApiRoutes, type GeolocationStatus } from '../h
import type { UnmockedLogger } from '../helpers/logger'
import { MockOverrides } from '../helpers/overrides'
import { resetFrozenTime } from '../helpers/clock'
import { describeLiveConfig, isLiveMode, liveConfig, type LiveConfig } from '../helpers/live'
import type { TxRecord } from '../helpers/provider'
import type { BoundaryRequest } from '../helpers/requests'
import { mockRpcRoutes, setMockNow, setYieldReplay } from '../helpers/rpc'
Expand All @@ -26,6 +27,15 @@ export interface BaseFixtures {
// Every handled API/subgraph/RPC request. Tests use this to prove source,
// identity, parameters, and request counts rather than only rendered shells.
boundaryRequests: BoundaryRequest[]
// Live-API surfaces resolved from E2E_LIVE_RESERVE_API / E2E_LIVE_ZAPPER_API
// (helpers/live.ts). Empty object = the default fully-offline suite. Specs
// read it to skip when their target is not configured; the API mock reads it
// to pass a surface through to the real deployment.
live: LiveConfig
// Response-contract failures recorded by the live passthrough. Fails the test
// at teardown, exactly like `unmockedCalls` — a live run that quietly accepts
// a drifted payload would validate nothing.
liveViolations: string[]
// Escape hatch for genuinely exploratory specs: when true, unmocked calls are
// still logged/attached but don't fail the test. Default false — a committed
// migration flow must fail on any unmocked RPC/API/subgraph/egress call.
Expand All @@ -44,6 +54,16 @@ export const test = base.extend<BaseFixtures>({
compliance: [DEFAULT_GEOLOCATION, { option: true }],
allowUnmocked: [false, { option: true }],

// oxlint-disable-next-line no-empty-pattern -- {} = no deps
live: async ({}, use) => {
await use(liveConfig())
},

// oxlint-disable-next-line no-empty-pattern -- {} = no deps
liveViolations: async ({}, use) => {
await use([])
},

// Fresh per test — a new instance means overrides never leak between tests.
// oxlint-disable-next-line no-empty-pattern -- Playwright derives fixture deps from the destructuring pattern; {} = no deps
overrides: async ({}, use) => {
Expand All @@ -63,7 +83,16 @@ export const test = base.extend<BaseFixtures>({

unmockedCalls: [
async (
{ page, compliance, overrides, txLog, boundaryRequests, allowUnmocked },
{
page,
compliance,
overrides,
txLog,
boundaryRequests,
allowUnmocked,
live,
liveViolations,
},
use,
testInfo
) => {
Expand Down Expand Up @@ -122,8 +151,14 @@ export const test = base.extend<BaseFixtures>({
geolocation: compliance,
overrides,
requests: boundaryRequests,
live,
liveViolations,
})

if (isLiveMode(live)) {
console.log(`[E2E] live API mode: ${describeLiveConfig(live)}`)
}

// WalletConnect / relay / explorer: fulfill empty (NOT abort) — connectors
// init eagerly on mount and aborts surface unhandled rejections.
await page.route('**walletconnect.com**', (r) => fulfillEmpty(r))
Expand Down Expand Up @@ -216,6 +251,18 @@ export const test = base.extend<BaseFixtures>({
`test hit ${calls.length} unmocked call(s):\n${calls.join('\n')}`
)
}

// Live contract drift is a failure of the deployment under validation —
// never a soft warning, and never silenced by allowUnmocked.
if (liveViolations.length) {
await testInfo.attach('live-contract-violations', {
body: liveViolations.join('\n'),
contentType: 'text/plain',
})
throw new Error(
`live API returned ${liveViolations.length} contract violation(s):\n${liveViolations.join('\n')}`
)
}
Comment on lines +254 to +265

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Attach live violations before the unmocked-calls throw.

The unmocked-calls check at Lines 249-253 throws before this block runs. If a test records both an unmocked call and a live contract violation, testInfo.attach('live-contract-violations', ...) never executes and the violation report is missing from the trace.

Move the attachment above the first throw so both artifacts always reach the report.

🐛 Proposed fix for the attachment ordering
       if (calls.length) {
         await testInfo.attach('unmocked-calls', {
           body: calls.join('\n'),
           contentType: 'text/plain',
         })
       }
+      if (liveViolations.length) {
+        await testInfo.attach('live-contract-violations', {
+          body: liveViolations.join('\n'),
+          contentType: 'text/plain',
+        })
+      }
       // Every committed test is strict by default. Exploratory work must opt out
       // explicitly with test.use({ allowUnmocked: true }).
       if (!allowUnmocked && calls.length) {
         throw new Error(
           `test hit ${calls.length} unmocked call(s):\n${calls.join('\n')}`
         )
       }
 
       // Live contract drift is a failure of the deployment under validation —
       // never a soft warning, and never silenced by allowUnmocked.
       if (liveViolations.length) {
-        await testInfo.attach('live-contract-violations', {
-          body: liveViolations.join('\n'),
-          contentType: 'text/plain',
-        })
         throw new Error(
           `live API returned ${liveViolations.length} contract violation(s):\n${liveViolations.join('\n')}`
         )
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/fixtures/base.ts` around lines 254 - 265, Move the live-contract
attachment logic from the liveViolations throw block to before the
unmocked-calls throw in the surrounding fixture flow, so
testInfo.attach('live-contract-violations', ...) executes whenever violations
are recorded even when unmocked calls also exist. Keep the existing
liveViolations error throw and attachment payload unchanged.

},
{ auto: true },
],
Expand Down
Loading
Loading