Skip to content
Open
Show file tree
Hide file tree
Changes from 54 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
7ecbda7
refactor: use setupTestConditions in tests
shoom3301 Aug 6, 2026
93fddb7
chore: add account activities locator
shoom3301 Aug 6, 2026
9ee9c1e
refactor: decompose mockSwapFulfillment
shoom3301 Aug 6, 2026
3c7a128
refactor: use types from sdk
shoom3301 Aug 6, 2026
c39705b
test: e2e [MO-06]
shoom3301 Aug 6, 2026
c4298c1
docs: add E2E SPEC SKILL
shoom3301 Aug 6, 2026
feb8fd4
docs: add E2E SCENARIO SKILL
shoom3301 Aug 6, 2026
4f712cf
chore: clean up
shoom3301 Aug 6, 2026
62021d2
test: e2e [LO-02]
shoom3301 Aug 6, 2026
4d91d20
test: e2e [MO-07]
shoom3301 Aug 6, 2026
75c59ee
test: e2e [MO-07] snackbar
shoom3301 Aug 6, 2026
5cad625
refactor: approve test
shoom3301 Aug 6, 2026
dcbf4cb
chore: add test
shoom3301 Aug 6, 2026
cdc302f
test: e2e [MO-09]
shoom3301 Aug 7, 2026
da5150e
test: e2e [MO-10]
shoom3301 Aug 7, 2026
ab0bd16
refactor: align market-orders tests to doc
shoom3301 Aug 7, 2026
f82fbd0
test: e2e [MO-03]
shoom3301 Aug 7, 2026
93acbf2
test: e2e [MO-08]
shoom3301 Aug 7, 2026
1e223f3
test: e2e [MO-11]
shoom3301 Aug 7, 2026
a4f8245
test: e2e [MO-22]
shoom3301 Aug 7, 2026
8cd093f
test: e2e [MO-30]
shoom3301 Aug 8, 2026
72db874
test: e2e [MO-42]
shoom3301 Aug 8, 2026
42eb059
test: e2e [MO-45]
shoom3301 Aug 8, 2026
e61b3a1
test: e2e [MO-46]
shoom3301 Aug 8, 2026
252b363
test: e2e [MO-54]
shoom3301 Aug 8, 2026
bb62509
refactor: simplify e2e tests
shoom3301 Aug 8, 2026
5f83da6
test: e2e MO-61
shoom3301 Aug 9, 2026
e3121c0
test: e2e MO-70 MO-71
shoom3301 Aug 9, 2026
62fd48a
chore: clean up code
shoom3301 Aug 10, 2026
4b19a9f
chore: mark as smoke
shoom3301 Aug 10, 2026
a04ff59
chore: fix test
shoom3301 Aug 10, 2026
547708c
chore: fix test
shoom3301 Aug 10, 2026
2dfc961
Merge branch 'feat/e2e-playwright-3' of https://github.com/cowprotoco…
shoom3301 Aug 10, 2026
c92fe9c
chore: update bundle sizes [automatic]
cowswap-release-sync[bot] Aug 10, 2026
28b73a3
chore: fix test
shoom3301 Aug 10, 2026
262e569
Merge remote-tracking branch 'origin/e2e/market-orders' into e2e/mark…
shoom3301 Aug 10, 2026
ca5b801
chore: fix test
shoom3301 Aug 10, 2026
252d0c7
feat(e2e): speed up updaters
shoom3301 Aug 10, 2026
eea34b8
test: e2e MO-61
shoom3301 Aug 10, 2026
ef92a74
test: e2e MO-14
shoom3301 Aug 10, 2026
199a9ab
test: e2e MO-47
shoom3301 Aug 10, 2026
4ce63d4
Merge branch 'feat/e2e-playwright-3' of https://github.com/cowprotoco…
shoom3301 Aug 10, 2026
dd355fc
Merge branch 'feat/e2e-playwright-3' of https://github.com/cowprotoco…
shoom3301 Aug 11, 2026
d18d76e
chore: fix lock
shoom3301 Aug 11, 2026
75bc9aa
chore: make tests stable
shoom3301 Aug 12, 2026
89a21e5
chore: update tests id
shoom3301 Aug 12, 2026
0fc344e
chore: update comments
shoom3301 Aug 12, 2026
8fb9e01
test(e2e): improve rpc node mocks
shoom3301 Aug 12, 2026
fe6b0cb
refactor: simplify e2e mocks
shoom3301 Aug 12, 2026
cefa072
chore: fix locator
shoom3301 Aug 12, 2026
c623450
chore: fix allowance mock
shoom3301 Aug 12, 2026
4597958
Merge branch 'feat/e2e-playwright-3' into e2e/market-orders
elena-zh Aug 12, 2026
5397421
test(e2e): add [CS-129] enable Hooks via settings toggle
elena-zh Aug 12, 2026
6aab3ab
chore: remove test
shoom3301 Aug 13, 2026
30dff46
Merge branch 'feat/e2e-playwright-3' of https://github.com/cowprotoco…
shoom3301 Aug 13, 2026
83e4bf7
chore: make tests stable
shoom3301 Aug 13, 2026
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
21 changes: 17 additions & 4 deletions apps/cowswap-e2e-tests/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,24 @@ Required env vars: `INTEGRATION_TEST_PRIVATE_KEY`, `REACT_APP_NETWORK_URL_111551
- Page objects hold `Locator`s as readonly properties set in the constructor, plus action methods
(`goto`, `enterSellAmount`, `clickSwap`, ...) that encapsulate waits. Add new locators/actions there,
not ad hoc selectors inside a spec.
- **Prefer the `setupTestConditions` fixture** (`src/support/setupTestConditions.ts`) over manually
chaining `goto` + `enterSellAmount` + `waitForQuote` + `mocks.balances.set`/`mocks.allowances.set`. It
wires up the whole "navigate to a trade, fund/allowance the wallet, type an amount, wait for its quote"
flow in one call, takes human-readable amounts (`{ WETH: '1' }`, not raw atoms). Reach for manual page-object calls only for
what `setupTestConditions` doesn't cover, e.g. changing the amount again mid-test.
- **Mock-driven scenarios that span multiple endpoints belong on the page object as a method**, not as a
free function in the spec file. Example: `SwapPage.mockSwapFulfillment(cowApi, balances, owner, chainId,
sellTokenBalanceBefore)` sets up `postOrder` + `accountOrders` + `orderStatus` + the balance debit/credit
together, because they describe one coherent thing ("the orderbook fulfilled this order") and every spec
needing that scenario should get it identically.
free function in the spec file. Example: `tradePage.mockOrderPosting(cowApi, owner)` sets up `postOrder` +
`accountOrders` together (the order shows up as `open` the moment it's posted), and returns a handle
whose `fulfill(balances, chainId, sellTokenBalanceBefore, buyTokenBalanceBefore)` you call whenever the test is ready for the
trade to settle — it's what flips `accountOrders` to `fulfilled`, debits/credits `balances`, and makes
`orderStatus` report `traded`. Posting and fulfilling are deliberately separate calls, not one bundled
step, so a spec can assert on the pending/open state before triggering settlement.
Comment thread
shoom3301 marked this conversation as resolved.
- **Prefer real CoW Protocol SDK types over hand-rolled interfaces** when shaping a mock's request/response
body. `@cowprotocol/sdk-order-book` (also re-exported wholesale by `@cowprotocol/cow-sdk`, already a
devDependency here) exports `OrderCreation` (the `postOrder` body), `Order` (an `accountOrders`/`order`
entry), `OrderStatus` (the status enum), and the rest of the real API shapes. Only
hand-roll a type for something genuinely local to this test app (`TradePage`, fixture helper options,
etc.), not for anything that crosses the wire to/from the CoW Protocol API.
- Use `test.describe(...)` + `test.beforeEach(...)` for setup every test in a file needs (e.g. giving the
wallet a default, sufficient token balance) instead of repeating `mocks.balances.set(...)` in every test
body. Individual tests can still override on top for their specific scenario.
Expand Down
3 changes: 2 additions & 1 deletion apps/cowswap-e2e-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"license": "ISC",
"dependencies": {
"viem": "2.48.8",
"@cowprotocol/cow-sdk": "9.2.6"
"@cowprotocol/cow-sdk": "9.2.6",
"@cowprotocol/sdk-order-book": "4.0.2"
},
"devDependencies": {
"@playwright/test": "1.49.1",
Expand Down
7 changes: 7 additions & 0 deletions apps/cowswap-e2e-tests/scripts/run-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
set -a
source .env
set +a
npx playwright test "$@"
63 changes: 53 additions & 10 deletions apps/cowswap-e2e-tests/src/fixtures/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,25 @@ import { createRpcProxyHandle, type RpcProxyHandle } from './rpcProxy'

import { installAllowances, type AllowancesMock } from '../mocks/allowances'
import { installBalances, type BalancesMock } from '../mocks/balances'
import { installBungee, type BungeeMock } from '../mocks/bungee'
import { installCowProtocolApi, type CowProtocolApiMock } from '../mocks/cowProtocolApi'
import { installNearIntents, type NearIntentsMock } from '../mocks/nearIntents'
import { installEthBlockNumber } from '../mocks/ethBlockNumber'
import { installEthEstimateGas } from '../mocks/ethEstimateGas'
import { installEthGetCode, type EthGetCodeMock } from '../mocks/ethGetCode'
import { installEthGetTransactionCount } from '../mocks/ethGetTransactionCount'
import { installLaunchDarkly, type LaunchDarklyMock } from '../mocks/launchDarkly'
import { installMulticall3 } from '../mocks/multicall3'
import { installSafeSdk, type SafeSdkMock } from '../mocks/safeSdk'
import { installTokenLists, type TokenListsMock } from '../mocks/tokenLists'
import { installUsdPrices, type UsdPricesMock } from '../mocks/usdPrices'
import { AccountModal } from '../pages/AccountModal'
import { AccountPage } from '../pages/AccountPage'
import { ConfirmModal } from '../pages/ConfirmModal'
import { HeaderPage } from '../pages/HeaderPage'
import { LimitPage } from '../pages/LimitPage'
import { SwapPage } from '../pages/SwapPage'
import { TwapPage } from '../pages/TwapPage'
import { logUnmockedRpcRequests } from '../support/logUnmockedRpcRequests'
import { mockOrderPosting } from '../support/mockOrderPosting'
import { createSetupTestConditions, type SetupTestConditions } from '../support/setupTestConditions'

import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions } from '@playwright/test'
Expand All @@ -23,18 +30,21 @@ export interface SharedFixtures {
limitPage: LimitPage
twapPage: TwapPage
accountPage: AccountPage
accountModal: AccountModal
confirmModal: ConfirmModal
header: HeaderPage
rpcProxy: RpcProxyHandle
setupTestConditions: SetupTestConditions
/** Page-agnostic order-mocking helpers shared by swap, limit and TWAP order flows. */
tradePage: { mockOrderPosting: typeof mockOrderPosting }
mocks: {
allowances: AllowancesMock
balances: BalancesMock
cowApi: CowProtocolApiMock
ethGetCode: EthGetCodeMock
tokenLists: TokenListsMock
safeSdk: SafeSdkMock
bungee: BungeeMock
nearIntents: NearIntentsMock
launchDarkly: LaunchDarklyMock
usdPrices: UsdPricesMock
}
}
Expand Down Expand Up @@ -62,6 +72,9 @@ export const sharedFixtures: Fixtures<
accountPage: async ({ page }, use) => {
await use(new AccountPage(page))
},
accountModal: async ({ page }, use) => {
await use(new AccountModal(page))
},
confirmModal: async ({ page }, use) => {
await use(new ConfirmModal(page))
},
Expand All @@ -71,6 +84,9 @@ export const sharedFixtures: Fixtures<
setupTestConditions: async ({ wallet, mocks, swapPage, limitPage, twapPage }, use) => {
await use(createSetupTestConditions({ wallet, mocks, swapPage, limitPage, twapPage }))
},
tradePage: async ({}, use) => {
await use({ mockOrderPosting })
},
rpcProxy: async ({}, use, testInfo) => {
const handle = createRpcProxyHandle(testInfo)
await handle.reset()
Expand All @@ -82,21 +98,48 @@ export const sharedFixtures: Fixtures<
// teardown. A plain (non-auto) fixture is only set up when requested, so without this the
// whole mock stack — including `assertNoUnmatched()` — would silently never run.
mocks: [
async ({ context }, use) => {
async ({ context }, use, testInfo) => {
// Diagnostic-only, opt-in via `LOG_UNMOCKED_RPC=1` — see `logUnmockedRpcRequests`'s own doc
// comment. Registered before every other mock below (and therefore before any manually
// installed one too, e.g. `mockSocketVerifier`, since those only get added once the test body
// starts running) so it only ever sees requests nothing else claimed.
if (process.env.LOG_UNMOCKED_RPC) {
logUnmockedRpcRequests({ context, worker: testInfo.workerIndex, test: testInfo.title })
}

// The order book API is mocked, so updaters can poll much faster without adding real load.
// See `getUpdaterInterval` in `libs/common-const/src/common.ts`.
await context.addInitScript(() => {
;(window as unknown as { __COWSWAP_E2E__?: boolean }).__COWSWAP_E2E__ = true
})

const allowances = installAllowances(context)
const balances = installBalances(context)
const cowApi = await installCowProtocolApi(context)
const ethGetCode = installEthGetCode(context)
installEthBlockNumber(context)
installEthEstimateGas(context)
installEthGetTransactionCount(context)
installMulticall3(context, { allowances })
const tokenLists = installTokenLists(context)
const safeSdk = installSafeSdk(context)
const bungee = installBungee(context)
const nearIntents = installNearIntents(context)
const launchDarkly = installLaunchDarkly(context)
const usdPrices = installUsdPrices(context)

await use({ allowances, balances, cowApi, tokenLists, safeSdk, bungee, nearIntents, usdPrices })
await use({
allowances,
balances,
cowApi,
ethGetCode,
tokenLists,
safeSdk,
launchDarkly,
usdPrices,
})

ethGetCode.reset()
tokenLists.reset()
bungee.reset()
nearIntents.reset()
await launchDarkly.reset()
usdPrices.reset()
await safeSdk.disable()
// Non-fatal, so it must run before the throwing assert below.
Expand Down
55 changes: 49 additions & 6 deletions apps/cowswap-e2e-tests/src/mocks/allowances/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { type Hex } from 'viem'

import {
areAddressesEqual,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
COW_PROTOCOL_VAULT_RELAYER_ADDRESS,
COW_PROTOCOL_VAULT_RELAYER_ADDRESS_STAGING,
} from '@cowprotocol/cow-sdk'

import {
classifyCall,
encodeAllowanceResult,
Expand All @@ -11,7 +18,7 @@ import {
} from './codec'
import { loadAllowancesFixture, parseAllowanceValue } from './fixture'
import { hasAnyEntry, isOwnerConfigured, resolveAllowance } from './resolve'
import { normalizeRpcUrl, resolveRpcChainIds, unconfiguredChainIds } from './rpcUrls'
import { normalizeRpcUrl, resolveRpcChainIds } from './rpcUrls'
import { allowanceKey, type AllowanceLookup, type AllowanceRead, type AllowanceValue } from './types'

import type { BrowserContext, Route } from '@playwright/test'
Expand All @@ -33,6 +40,15 @@ export interface AllowancesMock {
/** Non-fatal warning about queried-but-unconfigured owners and decode failures. */
reportUnknownOwners(): void
reset(): void
/**
* Resolve one already-decoded allowance read against the live fixture+override state, bypassing
* the URL-scoped route handler below entirely. Used by `mocks/multicall3.ts`'s host-agnostic
* `aggregate3` handler, which needs the exact same "override wins, else fixture, else 0" answer
* regardless of which real RPC host the app's independent read-only client happened to pick for a
* given batch — going through the same `resolveFor` the route handler itself uses keeps
* `reads()`/`reportUnknownOwners()` bookkeeping accurate no matter which handler answered.
*/
resolve(chainId: number, call: AllowanceCall): bigint
}

interface JsonRpcEntry {
Expand All @@ -56,14 +72,14 @@ export function installAllowances(context: BrowserContext): AllowancesMock {
'[allowances mock] No REACT_APP_NETWORK_URL_<chainId> env var is set, so no RPC traffic is intercepted ' +
'and allowances come from the real node. The suite requires REACT_APP_NETWORK_URL_11155111.',
)
} else {
const missing = unconfiguredChainIds()
if (missing.length > 0) {
console.info(`[allowances mock] not intercepting chains without an RPC override: ${missing.join(', ')}`)
}
}

function resolveFor(chainId: number, call: AllowanceCall): bigint {
if (!isVaultRelayerSpender(chainId, call.spender)) {
reads.push({ chainId, owner: call.owner, spender: call.spender, token: call.token, value: 0n })
return 0n
}

const value = resolveAllowance(fixture, overrides, call.owner, chainId, call.token)

reads.push({ chainId, owner: call.owner, spender: call.spender, token: call.token, value })
Expand Down Expand Up @@ -161,6 +177,9 @@ export function installAllowances(context: BrowserContext): AllowancesMock {
unknownOwners.clear()
problems.length = 0
},
resolve(chainId, call) {
return resolveFor(chainId, call)
},
}
}

Expand All @@ -184,6 +203,30 @@ async function fulfillJson(route: Route, body: unknown): Promise<void> {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
}

/**
* `set()`/the committed fixture key on `(owner, chainId, token)` alone — there's no `spender` in
* that key because every test-authored allowance here is really "let the trade proceed", i.e. an
* approval to the CoW VaultRelayer, the only spender any of this suite's trades ever check. Without
* this gate, `resolveFor` would hand that same value back for *any* spender's `allowance()` query on
* that token — including ones with nothing to do with trading. That's exactly what broke the
* cross-chain WETH tests: `useIsAnySwapAffectedUser` queries `allowance(account, ANYSWAP_V4_CONTRACT)`
* for a fixed set of tokens (WETH among them) independent of what's being traded, and a seeded WETH
* VaultRelayer allowance was leaking into that unrelated read, flipping the app into its AnySwap-hack
* warning page instead of the swap form. Gating on the real spender here — rather than widening the
* key to carry one, which would ripple into every `set()` call site, the fixture format, and their
* unit tests — keeps every existing caller's "just let the trade through" intent working while
* making every other spender read as unconfigured (0), matching what the real chain would show for
* an account this suite never actually approved anything on.
*/
function isVaultRelayerSpender(chainId: number, spender: string): boolean {
const vaultRelayer = (COW_PROTOCOL_VAULT_RELAYER_ADDRESS as Record<number, string>)[chainId]
const vaultRelayerStaging = (COW_PROTOCOL_VAULT_RELAYER_ADDRESS_STAGING as Record<number, string>)[chainId]
return (
(vaultRelayer !== undefined && areAddressesEqual(vaultRelayer, spender)) ||
(vaultRelayerStaging !== undefined && areAddressesEqual(vaultRelayerStaging, spender))
)
}

function localResult(
call: ClassifiedCall,
chainId: number,
Expand Down
31 changes: 0 additions & 31 deletions apps/cowswap-e2e-tests/src/mocks/bungee.ts

This file was deleted.

72 changes: 72 additions & 0 deletions apps/cowswap-e2e-tests/src/mocks/ethBlockNumber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { BrowserContext, Route } from '@playwright/test'

interface JsonRpcEntry {
id: number | string
method: string
}

// An arbitrary-but-real mainnet block number, captured once — nothing in this suite asserts on the
// actual value, so a fixed one is enough to remove the real dependency entirely.
const HARDCODED_BLOCK_NUMBER = '0x188bc6f'

/**
* `eth_blockNumber` goes out as a single, standalone JSON-RPC call (no Multicall3 batching, same
* as `eth_getCode`) to whichever real RPC/Infura endpoint the app's own independent client picked.
* Traced with `logUnmockedRpcRequests`/`LOG_UNMOCKED_RPC=1`: same class of real, rate-limited
* dependency as `eth_getCode` (`installEthGetCode`) that 429s under `pnpm e2e`'s full parallel
* load.
*/
export function installEthBlockNumber(context: BrowserContext): void {
void context.route('**/*', async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') return route.fallback()

let body: JsonRpcEntry | JsonRpcEntry[]
try {
body = request.postDataJSON() as JsonRpcEntry | JsonRpcEntry[]
} catch {
return route.fallback()
}

const entries = Array.isArray(body) ? body : [body]
if (!entries.some((entry) => entry?.method === 'eth_blockNumber')) return route.fallback()

if (entries.every((entry) => entry?.method === 'eth_blockNumber')) {
const payload = entries.map((entry) => ({ jsonrpc: '2.0', id: entry.id, result: HARDCODED_BLOCK_NUMBER }))
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(Array.isArray(body) ? payload : payload[0]),
})
}

return fulfillFromUpstream(route, entries)
})
}

/**
* A mixed batch alongside something else this mock doesn't own — patch only the `eth_blockNumber`
* slots and merge with the real response for the rest, with the same defensive try/catch as the
* allowances/SocketVerifier mocks so a flaky real upstream can't take the whole batch down.
*/
async function fulfillFromUpstream(route: Route, entries: JsonRpcEntry[]): Promise<void> {
try {
const upstream = await route.fetch()
const upstreamBody = (await upstream.json()) as JsonRpcEntry | JsonRpcEntry[]
const upstreamEntries = Array.isArray(upstreamBody) ? upstreamBody : [upstreamBody]
const byId = new Map(entries.map((entry) => [entry.id, entry]))

const payload = upstreamEntries.map((entry) => {
const original = byId.get((entry as JsonRpcEntry).id)
if (!original || original.method !== 'eth_blockNumber') return entry
return { jsonrpc: '2.0', id: original.id, result: HARDCODED_BLOCK_NUMBER }
})
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(Array.isArray(upstreamBody) ? payload : payload[0]),
})
} catch {
await route.fallback()
}
}
Loading
Loading