Skip to content

Commit cea816b

Browse files
committed
test(e2e): add Playwright + Anvil E2E infrastructure
Standalone /e2e/ package (not in pnpm workspace) with: - Playwright config: desktop + mobile projects, workers=1 in CI - Custom EIP-1193 provider injection adapted from frontwise/testwise: forwards JSON-RPC to local Anvil fork, bridges signing to ethers Wallet via page.exposeFunction, announces via EIP-6963 - Anvil scripts: Foundry Docker (local) and native binary (CI) - Risk-provider mock fixture: page.route pattern for Blowfish, Blockaid, GoPlus API responses driven by JSON fixtures - Smoke spec verifying provider injection works end-to-end Real component flows land in subsequent commits per Phase 2 of the testing strategy plan.
1 parent 6a41b51 commit cea816b

17 files changed

Lines changed: 1355 additions & 0 deletions

e2e/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Sepolia upstream RPC for Anvil fork. Public endpoints rate-limit Anvil's
2+
# fetch pattern; use a dedicated key (Alchemy free tier covers our usage).
3+
SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_ALCHEMY_KEY
4+
5+
# Override the playground URL for smoke tests against production.
6+
# Leave unset for local dev (Playwright auto-starts story dev server).
7+
# E2E_BASE_URL=https://story.txkit.dev

e2e/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
test-results
3+
playwright-report
4+
.env
5+
*.tsbuildinfo

e2e/README.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# txkit-e2e
2+
3+
End-to-end tests for txKit, run with Playwright against the story
4+
playground and a local Anvil fork.
5+
6+
**Standalone package** — not part of the pnpm workspace. Has its own
7+
`node_modules`. See [plans/txkit-testing-strategy-v0.1.md](../plans/txkit-testing-strategy-v0.1.md)
8+
for the full architecture rationale.
9+
10+
## Setup
11+
12+
```bash
13+
# 1. Install isolated deps
14+
cd e2e
15+
pnpm install --ignore-workspace
16+
17+
# 2. Install Playwright browsers
18+
pnpm exec playwright install chromium
19+
20+
# 3. Configure RPC for Anvil fork
21+
cp .env.example .env
22+
# Edit .env, paste your Alchemy/QuickNode Sepolia URL into SEPOLIA_RPC_URL
23+
24+
# 4. Verify Foundry is available
25+
anvil --version || curl -L https://foundry.paradigm.xyz | bash && foundryup
26+
```
27+
28+
## Run
29+
30+
```bash
31+
pnpm test # all projects (desktop + mobile)
32+
pnpm test:desktop # desktop chrome 1280x800
33+
pnpm test:mobile # iPhone 14 viewport
34+
pnpm test:headed # non-headless (watch the browser)
35+
pnpm test:debug # interactive Playwright debugger
36+
pnpm report # open last HTML report
37+
```
38+
39+
Playwright auto-starts both the story dev server (`pnpm --filter @txkit/story dev`)
40+
and Anvil fork (`./scripts/anvil-ci.sh`). Just run `pnpm test`.
41+
42+
For smoke tests against the deployed playground:
43+
44+
```bash
45+
E2E_BASE_URL=https://story.txkit.dev pnpm test
46+
```
47+
48+
## Architecture
49+
50+
- **EIP-1193 injection**`helpers/eip1193-provider.js` replaces
51+
`window.ethereum` with a stub that forwards to Anvil and bridges
52+
signing to ethers `Wallet` via `page.exposeFunction`. Adapted from
53+
StakeWise testwise.
54+
- **Anvil fork** — Sepolia fork at `localhost:8545`, block-time 2s.
55+
`anvil_impersonateAccount` + `anvil_setBalance` give each test a
56+
funded address without needing a real private key.
57+
- **EIP-6963 announce** — the injected provider announces itself so
58+
wagmi's connector discovery picks it up automatically.
59+
- **Risk fixtures**`fixtures/risk/*.json` capture real Blowfish /
60+
Blockaid / GoPlus responses. `risk.use('blowfish-warn-token-drain')`
61+
routes outbound API calls to the fixture instead of upstream.
62+
63+
## Writing a new spec
64+
65+
```ts
66+
import { test, expect } from '../../fixtures'
67+
68+
69+
test('TransactionButton runs full happy path', async ({ page, wallet }) => {
70+
await wallet.init({ chain: 'sepolia' })
71+
await page.goto('/?state=pending')
72+
await wallet.connect()
73+
74+
await page.locator('[data-testid="transaction-button-main"]').click()
75+
76+
await expect(
77+
page.locator('[data-testid="transaction-button-main"]')
78+
).toHaveAttribute('data-state', 'completed', { timeout: 30_000 })
79+
})
80+
```
81+
82+
State of the world is controlled via URL search params (story reads
83+
them in `useControls()`):
84+
85+
```
86+
?state=signing # force a specific TransactionButton state
87+
?safetyDelayMs=5000 # safety countdown
88+
?warnMaxApproval=true # max-approval risk warning enabled
89+
?riskMode=warn # mock risk-provider mode
90+
?clearSigning=registry|fallback # ERC-7730 vs ABI fallback
91+
```
92+
93+
## Debugging a failing test
94+
95+
```bash
96+
# Replay the trace from a failed run
97+
pnpm exec playwright show-trace test-results/.../trace.zip
98+
99+
# Or step through interactively
100+
pnpm test:debug -g "your test name"
101+
```
102+
103+
Each retry produces a video (`test-results/.../video.webm`) and
104+
screenshot for forensics.
105+
106+
## CI
107+
108+
Single Playwright workflow runs both projects with `workers=1` (Anvil
109+
shared state). Foundry preinstalled via
110+
`foundry-rs/foundry-toolchain@v1` action. See `.github/workflows/e2e.yml`.
111+
112+
## Conventions
113+
114+
- Test files end in `.spec.ts`, never `.test.ts`
115+
- Imports through `../../fixtures` (extended `test`, not raw Playwright)
116+
- Wallet init **before** `page.goto` so injection happens on first frame
117+
- Test names use plain ASCII (`->` not ``); they appear in CI logs

e2e/fixtures/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { test as base, expect } from '@playwright/test'
2+
3+
import { setupWallet, type WalletFixture } from './wallet'
4+
import { mockRiskProvider, type RiskMock } from './risk'
5+
6+
7+
type Fixtures = {
8+
wallet: WalletFixture
9+
risk: RiskMock
10+
}
11+
12+
13+
/**
14+
* Extended Playwright test with txKit-specific fixtures.
15+
*
16+
* Usage:
17+
* import { test, expect } from '../../fixtures'
18+
*
19+
* test('connects', async ({ page, wallet }) => {
20+
* await wallet.init({ chain: 'sepolia' })
21+
* await page.goto('/')
22+
* await wallet.connect()
23+
* // assertions...
24+
* })
25+
*/
26+
export const test = base.extend<Fixtures>({
27+
wallet: async ({ page }, use) => {
28+
const wallet = await setupWallet({ page })
29+
await use(wallet)
30+
},
31+
risk: async ({ page }, use) => {
32+
const mock = mockRiskProvider({ page })
33+
await use(mock)
34+
await mock.reset()
35+
},
36+
})
37+
38+
39+
export { expect }

e2e/fixtures/risk.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import type { Page } from '@playwright/test'
5+
6+
7+
const __filename = fileURLToPath(import.meta.url)
8+
const __dirname = path.dirname(__filename)
9+
10+
const RISK_FIXTURES_DIR = path.join(__dirname, 'risk')
11+
12+
13+
// URL patterns that match real risk-provider APIs. Tests that opt into
14+
// `risk.use(...)` get every matching outbound request fulfilled with the
15+
// fixture payload instead of hitting the live API.
16+
const PROVIDER_PATTERNS: Record<string, string> = {
17+
blowfish: '**/api.blowfish.xyz/**',
18+
blockaid: '**/api.blockaid.io/**',
19+
goplus: '**/api.gopluslabs.io/**',
20+
}
21+
22+
23+
export type RiskFixture =
24+
| 'blowfish-safe'
25+
| 'blowfish-warn-token-drain'
26+
| 'blowfish-block-malicious'
27+
| 'blockaid-phishing-domain'
28+
| 'goplus-honeypot'
29+
30+
export type RiskMock = {
31+
use: (fixture: RiskFixture) => Promise<void>
32+
reset: () => Promise<void>
33+
}
34+
35+
36+
const detectProvider = (fixture: RiskFixture): string => {
37+
const provider = fixture.split('-')[0]
38+
39+
if (!PROVIDER_PATTERNS[provider]) {
40+
throw new Error(`Unknown risk provider for fixture ${fixture}`)
41+
}
42+
43+
return provider
44+
}
45+
46+
const loadFixture = (fixture: RiskFixture): unknown => {
47+
const filePath = path.join(RISK_FIXTURES_DIR, `${fixture}.json`)
48+
49+
if (!fs.existsSync(filePath)) {
50+
throw new Error(`Risk fixture not found: ${filePath}`)
51+
}
52+
53+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
54+
}
55+
56+
57+
/**
58+
* Risk-provider mock fixture. Tests that need to assert behavior on
59+
* Blowfish/Blockaid/GoPlus responses register a fixture before the
60+
* `transaction-button-main` click; matching outbound requests get
61+
* intercepted at the network layer (no upstream call made).
62+
*/
63+
export const mockRiskProvider = ({ page }: { page: Page }): RiskMock => {
64+
let activeRoute: string | null = null
65+
66+
return {
67+
use: async (fixture: RiskFixture) => {
68+
const provider = detectProvider(fixture)
69+
const pattern = PROVIDER_PATTERNS[provider]
70+
const payload = loadFixture(fixture)
71+
72+
if (activeRoute) {
73+
await page.unroute(activeRoute)
74+
}
75+
76+
await page.route(pattern, (route) => (
77+
route.fulfill({
78+
status: 200,
79+
contentType: 'application/json',
80+
body: JSON.stringify(payload),
81+
})
82+
))
83+
84+
activeRoute = pattern
85+
},
86+
87+
reset: async () => {
88+
if (activeRoute) {
89+
await page.unroute(activeRoute)
90+
activeRoute = null
91+
}
92+
},
93+
}
94+
}

e2e/fixtures/wallet.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Wallet } from 'ethers'
2+
import type { Page } from '@playwright/test'
3+
4+
import { initProvider } from '../helpers/initProvider'
5+
import type { SupportedChain } from '../helpers/chains'
6+
7+
8+
type InitInput = {
9+
chain?: SupportedChain
10+
privateKey?: string
11+
address?: string
12+
}
13+
14+
export type WalletFixture = {
15+
init: (input?: InitInput) => Promise<void>
16+
connect: () => Promise<void>
17+
address: () => string
18+
}
19+
20+
21+
type Internal = {
22+
address: string | null
23+
}
24+
25+
26+
const formatTestid = (id: string): string => `[data-testid="${id}"]`
27+
28+
29+
/**
30+
* Wallet fixture. Call `wallet.init()` BEFORE `page.goto()` so that the
31+
* EIP-1193 provider is injected on the first frame. After the page loads,
32+
* call `wallet.connect()` to drive the ConnectWallet UI through the modal.
33+
*/
34+
export const setupWallet = async ({ page }: { page: Page }): Promise<WalletFixture> => {
35+
const internal: Internal = { address: null }
36+
37+
return {
38+
init: async (input = {}) => {
39+
const privateKey = input.privateKey || Wallet.createRandom().privateKey
40+
const { address } = await initProvider({
41+
page,
42+
chain: input.chain || 'sepolia',
43+
address: input.address,
44+
privateKey,
45+
})
46+
47+
internal.address = address
48+
},
49+
50+
connect: async () => {
51+
if (!internal.address) {
52+
throw new Error('wallet.connect(): call wallet.init() first')
53+
}
54+
55+
// Click the ConnectWallet button. Top-level testid is forwarded
56+
// from the `data-testid` prop in stories; sub-buttons need their
57+
// own testids (added in the data-testid audit pass).
58+
await page.locator(formatTestid('connect-wallet-button')).first().click()
59+
60+
// Pick the first wallet in the modal. Our injected provider
61+
// announces itself via EIP-6963 so it lands as a top option.
62+
await page.locator(formatTestid('wallet-modal')).waitFor()
63+
await page.locator(formatTestid('wallet-modal-item')).first().click()
64+
65+
// Wait for connected state to render.
66+
await page.locator(formatTestid('connected-address')).waitFor({ timeout: 10_000 })
67+
},
68+
69+
address: () => {
70+
if (!internal.address) {
71+
throw new Error('wallet.address(): call wallet.init() first')
72+
}
73+
74+
return internal.address
75+
},
76+
}
77+
}

e2e/helpers/chains.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Chain registry for the E2E provider injection. The values match what
3+
* txKit's testnet preset registers in wagmi (Sepolia + mainnet for ENS).
4+
*
5+
* `rpcUrl` should point at your local Anvil fork. Mainnet entry has no
6+
* local fork — ENS resolution falls through to the public RPC, which is
7+
* fine because tests don't write to mainnet.
8+
*/
9+
export const ANVIL_RPC_URL = 'http://localhost:8545'
10+
11+
export type SupportedChain = 'sepolia' | 'mainnet'
12+
13+
type ChainEntry = {
14+
hexadecimalChainId: string
15+
decimalChainId: number
16+
name: string
17+
rpcUrl: string
18+
}
19+
20+
export const chains: Record<string, ChainEntry> = {
21+
// Sepolia (txKit story default)
22+
'0xaa36a7': {
23+
hexadecimalChainId: '0xaa36a7',
24+
decimalChainId: 11155111,
25+
name: 'Sepolia',
26+
rpcUrl: ANVIL_RPC_URL,
27+
},
28+
// Mainnet (ENS lookups). Public read-only endpoint - no fork.
29+
'0x1': {
30+
hexadecimalChainId: '0x1',
31+
decimalChainId: 1,
32+
name: 'Ethereum',
33+
rpcUrl: 'https://ethereum-rpc.publicnode.com',
34+
},
35+
}
36+
37+
export const defaultChainIdHex = '0xaa36a7'
38+
39+
export const getChain = (id: SupportedChain): ChainEntry => {
40+
const hex = id === 'sepolia' ? '0xaa36a7' : '0x1'
41+
const chain = chains[hex]
42+
43+
if (!chain) {
44+
throw new Error(`Unknown chain: ${id}`)
45+
}
46+
47+
return chain
48+
}

0 commit comments

Comments
 (0)