Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

End-to-end test plan — @koed_jang/apyx-sdk

Goals

Catch every regression reachable from a real consumer before it ships to npm. Concretely:

  1. SDK library — every public method on ApyxClient.{apxUSD,apyUSD,apyUSDRateView} returns the right shape, writes land on-chain, errors throw the declared error types.
  2. CLI — every subcommand in the registry works (reads and writes), every flag is honoured, exit codes are correct, help output is sane.
  3. Playground — connect wallet → read panels populate → deposit/redeem writes settle → toasts + tx log reflect state transitions.
  4. Packaging — the published tarball resolves as ESM from a Vite app and as CJS from Node; bin.apyx runs after npm install -g.

Out of scope for v0 of the test plan: cross-version compatibility (we pin viem 2.x), mobile wallets, gas-bump / retry middleware (not in the SDK), performance benchmarks.

Environment matrix

LayerRuntimeRPCSigning
Unit-E2ENode 20, vitestnone (mocked)mocked
Fork-integrationNode 20, vitest, anvilanvil mainnet fork pinned to a known blockephemeral private key loaded via privateKeyToAccount
Fork-integration (Ledger path)Node 20, vitestanvil forkmocked LedgerAppHandle — CI has no hardware
PlaygroundPlaywright Chromiumanvil fork (injected into the app via --rpc-url equivalent — the RPC bar input)Playwright-mocked injected provider wrapping the anvil RPC
PackagingNode 20, ephemeral workspace

Anvil is the one non-npm prerequisite. CI installs Foundry via the foundry-rs/foundry-toolchain action.

Fixtures

Anvil fork

test/e2e/fixtures/anvil.ts:

  • startAnvil({ forkUrl, forkBlockNumber }) — spawns anvil --fork-url <url> --fork-block-number <block>, returns { rpcUrl: 'http://127.0.0.1:<port>', stop() }.
  • Picks a free port per test run to allow parallel suites.
  • forkUrl sourced from TEST_ETH_RPC_URL env var. CI sets this to a dedicated Alchemy key in the repo secrets (separate from any developer’s personal key). Locally, developer can set it too.
  • forkBlockNumber pinned to a known-good post-apyUSD-deployment block so fixtures are deterministic.

Funded test account

Since apxUSD is permissioned (minted via MinterV0 with authorized minter signatures), we skip the real minter and set storage directly:

  • giveApxUSD({ client, account, amount }) uses viem’s setStorageAt against anvil to write the balance into apxUSD’s _balances mapping slot for account, and bumps the _totalSupply slot to match. Slot is computed once from the known storage layout (UUPS proxy storage slot + OZ ERC20 layout — documented in the fixture file).
  • anvil_setBalance for gas ETH.

If the storage-slot approach proves fragile (ABI or upgrade drift), fall back to impersonating the minter + submitting a signed order — wrapper lives in the same fixture file.

CLI runner

test/e2e/fixtures/cli.ts:

  • runCli(args, { cwd, env, stdin }) — spawns node dist/cli.cjs <args>, returns { stdout, stderr, exitCode }.
  • withTempConfig({ profile }, cb) — writes ./apyx.config.json and a hex key file into a unique tempdir, runs cb with { cwd, keyPath }, tears down.

Test categories

1. Unit-E2E (already in test/)

Current suites already cover the “flags parse correctly, registry is coherent, config resolves, completer matches” layer (95 tests across 12 files). Nothing to add here as part of this plan — just keep passing.

2. SDK fork-integration (test/e2e/sdk/**)

Each spec file owns one contract surface. All use the shared anvil fixture.

sdk/apxUSD.fork.spec.ts

  • reads: balanceOf, allowance, totalSupply, supplyCap, paused, decimals, nonces — assert live values against a known block.
  • writes: approve({ spender, amount }) → balance of allowance(owner, spender) goes from old → new; transfer({ to, amount }) decrements sender / increments receiver; permit(...) with a freshly-signed typed data → allowance updated without an approve tx.
  • error: write without account throws WalletClientRequiredError.

sdk/apyUSD.fork.spec.ts

  • reads: exchangeRate, totalAssets, convertToShares, convertToAssets, previewDeposit/Mint/Withdraw/Redeem, maxDeposit/Mint/Redeem/Withdraw, balanceOf, asset, decimals — sanity against the live fork block.
  • write: deposit({ assets, receiver }) — pre-condition: approve. Post-condition: apyUSD.balanceOf(receiver) grew by roughly previewDeposit(assets), apxUSD.balanceOf(depositor) shrank by assets, totalAssets grew.
  • write: redeem({ shares, receiver, owner }) — reverses the above (modulo unlock flow if the deployment uses it — if so, skip-tag and track in a follow-up; redeem may route through UnlockToken).
  • error: revert paths for zero amount.

sdk/apyUSDRateView.fork.spec.ts

  • apy(), annualizedYield(), vault() return non-negative bigints.
  • Mainnet profile: view is wired. Base profile: apyx.apyUSDRateView is undefined (asserted at client-construction time — no network needed).

sdk/client.fork.spec.ts

  • createApyxClient with http read transport + distinct walletTransport — read + write go through different URLs, verified by pointing each at a different anvil instance on two ports.
  • addresses override — passing a bogus apyUSD address surfaces a revert/zero-response path cleanly.
  • UnsupportedChainError when chain is not in the registry.

3. CLI fork-integration (test/e2e/cli/**)

Spawns dist/cli.cjs via execFile against the anvil fork.

cli/config.e2e.spec.ts

  • apyx config init with piped stdin writes a valid file.
  • apyx config show / path return expected output.
  • Unknown profile → exit 1.
  • Missing config → exit 1 with the canonical message.

cli/repl.e2e.spec.ts (scripted via stdin)

  • Feed await apyx.apyUSD.exchangeRate(); .exit\n — stdout includes a bigint literal; exit 0.
  • .env <other-profile> emits a second banner.

cli/methods.e2e.spec.ts — drives the whole registry

For every read in CONTRACT_COMMANDS:

  • run apyx <contract> <method> [flags] against the fork, assert stdout parses as a bigint or JSON, exit 0.

For every write:

  • run via a profile whose key is the funded test account.
  • assert stdout lines: hash: 0x…, status: success, block: <n>, gas: <n>. Exit 0.
  • with --no-wait: only hash: line, exit 0.
  • Trigger a revert (e.g. redeem 0 shares) → exit 1, stderr carries the viem shortMessage.

cli/session-flags.e2e.spec.ts

  • --rpc-url override observable via a read that’s only possible on the override URL (e.g. first anvil has state X, second has state Y — same contract address, different balance after a write).
  • --chain base flip on a profile configured as ethereum → reads apyUSD on base addresses (tested on a Base fork spun up alongside).
  • --key-path override swaps the signer; writes come from the new address.
  • --address-apyusd 0x… override reads from a different apyUSD (another test contract deployed on the fork via setStorageAt trick, or simply a bogus address that surfaces an error cleanly).

cli/ledger.e2e.spec.ts (unit-ish, no anvil)

  • Inject a mock LedgerAppHandle via an exported factory hook. Run apyx apyUSD deposit against a local anvil fork — signer is the mock Ledger, tx hash returns, receipt arrives. Exercises the full signTransaction round-trip without hardware.
  • If the mock returns an error on signTransaction → exit 1 with a useful message.

4. Packaging (test/e2e/packaging/**)

packaging/tarball.spec.ts

  • pnpm pack → install the resulting tarball into two ephemeral workspaces:
    • a Node CJS consumer: require('@koed_jang/apyx-sdk') returns createApyxClient, APYX_ADDRESSES, ABIs; a smoke http() + mainnet read works.
    • a Vite + React consumer: import { createApyxClient } from '@koed_jang/apyx-sdk' compiles, smoke test via vite-node.
  • npm install -g the tarball → apyx --version prints the package version.
  • Optional Ledger deps are listed in the extracted package.json but not required by default install.

5. Playground E2E (example/e2e/**, Playwright)

Anvil fork running; playground served via vite preview on a fixed port. Playwright drives Chromium with an injected EIP-1193 provider wrapping the anvil RPC.

  • Connection: load page, inject provider, click Connect, assert the truncated address + green dot appear.
  • Unsupported chain banner: swap the provider’s chainId to 42161, assert amber banner + “Switch” button appears.
  • AccountPanel: balances populate, raw bigint + formatted both visible, copy button works (clipboard mocked).
  • RatePanel: apy, annualizedYield, exchangeRate, totalAssets all non-empty; Refresh triggers re-fetch.
  • Deposit: enter 1 apxUSD, click Approve → toast pending → success in under TEST_TX_TIMEOUT_MS (parametric). Then click Deposit → second toast chain.
  • Redeem: half the resulting shares, click Redeem, toast cycles, AccountPanel balances update automatically within the 10s ceiling without manual Refresh.
  • Error toast: redeem 0 shares, assert the red toast with a decoded revert message, and that it auto-dismisses after ~10s.
  • MAX button: deposit form sets input to the full balance (exact bigint round-trip).
  • Raw Read Console: select apyUSD → totalAssets, click Call, assert the JSON matches the RatePanel.
  • RPC bar: paste a custom URL, reload page, value persists from localStorage; Reset clears.

CI integration

Two workflow jobs, gated by path filters:

  • ci.yml (existing) — installs deps, runs pnpm typecheck, pnpm test (unit-E2E), pnpm build, playground build. Stays fast (~1 min).
  • e2e.yml (new) — runs on main + on PRs touching src/**, example/src/**, abis/**. Installs Foundry via foundry-rs/foundry-toolchain@v1, boots anvil forks as needed, runs pnpm test:e2e. Time budget ~8 min.
  • packaging.yml (new) — cron nightly + on release tag. Runs pnpm pack + the packaging specs. Catches files / exports misconfigurations that the unit suites miss.

Secrets required:

  • TEST_ETH_RPC_URL — RPC for the mainnet fork base. Alchemy’s free tier is sufficient for the block ranges we need.

Tooling choices

  • Test runner: vitest for everything except the browser playground (Playwright).
  • Anvil runner: child_process.spawn + port probe helper. No docker.
  • Fork block: a constant FORK_BLOCK_NUMBER in a shared fixture. Bump only deliberately, with an accompanying PR note on which fixture-value goldens shift.
  • Playwright: stable config in example/playwright.config.ts. Chromium only — we don’t ship Safari/Firefox-specific code.

Issues to open (one PR each)

  1. chore/e2e-anvil-fixture — shared anvil spawn/teardown + giveApxUSD storage-slot helper. Lands the fixture, no test specs yet.
  2. test/e2e-sdk-reads — per-contract read specs on top of the fixture.
  3. test/e2e-sdk-writes — deposit / approve / redeem write round-trips.
  4. test/e2e-cli-config-and-repl — config commands + REPL-via-stdin specs.
  5. test/e2e-cli-methods — driver over CONTRACT_COMMANDS for every read + write.
  6. test/e2e-cli-session-flags — flag-override coverage.
  7. test/e2e-cli-ledger-mock — Ledger signer path with a mocked hw-app-eth.
  8. test/e2e-packaging-tarballpnpm pack + install-and-import smoke in CJS + ESM.
  9. test/e2e-playground-playwright — browser-side coverage.
  10. ci/e2e-workflow — new GitHub Actions workflow(s) wiring the above behind path filters.

Each issue lists its slice of the plan so PRs stay single-purpose.

Rollout phases

Phase 1 (quick win): issues 1–3. SDK read + write confidence against a fork — the biggest source of silent regressions today.

Phase 2 (CLI depth): issues 4–7. Catches flag-parsing and dispatch bugs that unit-E2E can’t.

Phase 3 (publishable): issues 8 + 10. Packaging + CI. This is the gate for v0.2.0 going out to npm.

Phase 4 (UI): issue 9. Playground is already hand-tested; Playwright catches regressions once we start iterating on it more.

Open questions

  • MinterV0 path for funding apxUSD — the storage-slot trick assumes OZ’s ERC20Upgradeable layout under a UUPS proxy. Confirm the slot with a one-off test before committing the fixture. If a proxy layout quirk breaks it, fall back to impersonating an approved minter and submitting a real signed order (fixture complexity roughly doubles).
  • Base fork — the plan assumes we can run two anvils in parallel for cross-chain flag tests. Fine if Foundry’s anvil is installed once; still O(2×RAM). If CI slots are a constraint, merge the --chain base override tests into a single fork cycle by reusing one anvil process with different fork-url restarts.
  • Playwright RPC stubbing — Playwright’s Route.fulfill lets us intercept JSON-RPC responses, but the playground uses the wallet’s RPC once connected. If that stays true in the final build, the injected provider must proxy to anvil directly — not through Playwright’s route interceptor.

When Phase 1 starts, lock in answers via fixture-file comments.