End-to-end test plan — @koed_jang/apyx-sdk
Goals
Catch every regression reachable from a real consumer before it ships to npm. Concretely:
- SDK library — every public method on
ApyxClient.{apxUSD,apyUSD,apyUSDRateView}returns the right shape, writes land on-chain, errors throw the declared error types. - CLI — every subcommand in the registry works (reads and writes), every flag is honoured, exit codes are correct, help output is sane.
- Playground — connect wallet → read panels populate → deposit/redeem writes settle → toasts + tx log reflect state transitions.
- Packaging — the published tarball resolves as ESM from a Vite app and as CJS from Node;
bin.apyxruns afternpm 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
| Layer | Runtime | RPC | Signing |
|---|---|---|---|
| Unit-E2E | Node 20, vitest | none (mocked) | mocked |
| Fork-integration | Node 20, vitest, anvil | anvil mainnet fork pinned to a known block | ephemeral private key loaded via privateKeyToAccount |
| Fork-integration (Ledger path) | Node 20, vitest | anvil fork | mocked LedgerAppHandle — CI has no hardware |
| Playground | Playwright Chromium | anvil fork (injected into the app via --rpc-url equivalent — the RPC bar input) | Playwright-mocked injected provider wrapping the anvil RPC |
| Packaging | Node 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 })— spawnsanvil --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.
forkUrlsourced fromTEST_ETH_RPC_URLenv 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.forkBlockNumberpinned 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’ssetStorageAtagainst anvil to write the balance into apxUSD’s_balancesmapping slot foraccount, and bumps the_totalSupplyslot to match. Slot is computed once from the known storage layout (UUPS proxy storage slot + OZ ERC20 layout — documented in the fixture file).anvil_setBalancefor 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 })— spawnsnode dist/cli.cjs <args>, returns{ stdout, stderr, exitCode }.withTempConfig({ profile }, cb)— writes./apyx.config.jsonand a hex key file into a unique tempdir, runscbwith{ 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 ofallowance(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 roughlypreviewDeposit(assets),apxUSD.balanceOf(depositor)shrank byassets,totalAssetsgrew. - 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.apyUSDRateViewisundefined(asserted at client-construction time — no network needed).
sdk/client.fork.spec.ts
createApyxClientwithhttpread transport + distinctwalletTransport— read + write go through different URLs, verified by pointing each at a different anvil instance on two ports.addressesoverride — passing a bogusapyUSDaddress surfaces a revert/zero-response path cleanly.UnsupportedChainErrorwhen 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 initwith piped stdin writes a valid file.apyx config show/pathreturn 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: onlyhash: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-urloverride 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 baseflip on a profile configured as ethereum → reads apyUSD on base addresses (tested on a Base fork spun up alongside).--key-pathoverride 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
LedgerAppHandlevia an exported factory hook. Runapyx apyUSD depositagainst 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')returnscreateApyxClient,APYX_ADDRESSES, ABIs; a smokehttp()+ mainnet read works. - a Vite + React consumer:
import { createApyxClient } from '@koed_jang/apyx-sdk'compiles, smoke test via vite-node.
- a Node CJS consumer:
npm install -gthe tarball →apyx --versionprints 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,totalAssetsall 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, runspnpm typecheck,pnpm test(unit-E2E),pnpm build, playground build. Stays fast (~1 min).e2e.yml(new) — runs onmain+ on PRs touchingsrc/**,example/src/**,abis/**. Installs Foundry viafoundry-rs/foundry-toolchain@v1, boots anvil forks as needed, runspnpm test:e2e. Time budget ~8 min.packaging.yml(new) — cron nightly + on release tag. Runspnpm pack+ the packaging specs. Catchesfiles/exportsmisconfigurations 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_NUMBERin 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)
chore/e2e-anvil-fixture— shared anvil spawn/teardown +giveApxUSDstorage-slot helper. Lands the fixture, no test specs yet.test/e2e-sdk-reads— per-contract read specs on top of the fixture.test/e2e-sdk-writes— deposit / approve / redeem write round-trips.test/e2e-cli-config-and-repl— config commands + REPL-via-stdin specs.test/e2e-cli-methods— driver overCONTRACT_COMMANDSfor every read + write.test/e2e-cli-session-flags— flag-override coverage.test/e2e-cli-ledger-mock— Ledger signer path with a mocked hw-app-eth.test/e2e-packaging-tarball—pnpm pack+ install-and-import smoke in CJS + ESM.test/e2e-playground-playwright— browser-side coverage.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
ERC20Upgradeablelayout 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
anvilis installed once; stillO(2×RAM). If CI slots are a constraint, merge the--chain baseoverride tests into a single fork cycle by reusing one anvil process with different fork-url restarts. - Playwright RPC stubbing — Playwright’s
Route.fulfilllets 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.