cowswap-e2e-tests AGENTS.md
September 22, 2026 · View on GitHub
Root rules: ../../AGENTS.md (global safety, workflow, and verification baseline).
This file: operational conventions and hard-won debugging notes for agents working in this app, not a
restatement of the design doc.
App commands
- Run all tests:
pnpx nx run cowswap-e2e-tests:e2e(wrapsplaywright test) - Run one test while iterating:
npx playwright test src/tests/<file>.spec.ts -g "<title substring>" - Smoke only:
pnpx nx run cowswap-e2e-tests:e2e:smoke - Playwright UI mode:
pnpx nx run cowswap-e2e-tests:e2e:ui - Lint:
pnpx nx run cowswap-e2e-tests:lint - Unit-test the mocks themselves (pure functions, no browser):
npx tsx --test "src/**/*.test.ts" - Build the Synpress/MetaMask wallet cache (required once, only for the Synpress fixtures — see below):
pnpm e2e:build-cache
Required env vars: INTEGRATION_TEST_PRIVATE_KEY, REACT_APP_NETWORK_URL_11155111 (Sepolia RPC). See
.env.example and the README for the full list.
Writing tests
- Every spec follows the same shape: import
test/expectfrom a fixtures entrypoint, drive the flow through a page object (src/pages/*.ts), assert throughmocks. - Two wallet entrypoints — pick deliberately:
../fixtures/mockWallet— a deterministic viem account derived fromINTEGRATION_TEST_PRIVATE_KEY, injected as an EIP-6963 provider. Signing is instant, in-process, no extension. Use this for everything that doesn't need to exercise real wallet-extension UI. This is what every current spec in this app uses.../fixtures(Synpress) — a real MetaMask extension. Slower (~20-25s connect flow alone), needs the wallet cache built first. Only reach for this to test the actual connect/approve/network-switch popups themselves.
- Page objects hold
Locators 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
setupTestConditionsfixture (src/support/setupTestConditions.ts) over manually chaininggoto+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 whatsetupTestConditionsdoesn'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:
tradePage.mockOrderPosting(cowApi, owner)sets uppostOrder+accountOrderstogether (the order shows up asopenthe moment it's posted), and returns a handle whosefulfill(balances, chainId, sellTokenBalanceBefore, buyTokenBalanceBefore)you call whenever the test is ready for the trade to settle — it's what flipsaccountOrderstofulfilled, debits/creditsbalances, and makesorderStatusreporttraded. Posting and fulfilling are deliberately separate calls, not one bundled step, so a spec can assert on the pending/open state before triggering settlement. - 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) exportsOrderCreation(thepostOrderbody),Order(anaccountOrders/orderentry),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 repeatingmocks.balances.set(...)in every test body. Individual tests can still override on top for their specific scenario. - Wait for the specific value you expect, not just "non-empty." The swap form defaults the sell input
to the full wallet balance before you ever type anything, firing its own quote for that amount. A check
like
expect(outputAmount).not.toHaveValue('')is satisfied by that stale quote — you can end up confirming a trade for the wrong amount because the fresh quote for what you actually typed hadn't landed yet. Assert the exact value (or poll for stability) instead. - Prefer asserting against what the app actually did, not a hand re-derived number. The app applies
its own slippage (observed ~0.5%) and the fixture's own
protocolFeeBpson top of whateverquoteyou return. Readreq.bodyinside apostOrder/similar override and use those values for your balance/UI assertions rather than recomputing slippage math yourself.
Using mocks
mocks is auto-installed for every test (you don't need to request it to get the lockdown/reset
behavior, only to call its methods). Sub-mocks:
mocks.allowances.set(owner, chainId, { [token]: rawAtomString })— mocks ERC20allowance()reads (including when batched into a Multicall3aggregate3), intercepted host-agnostically by matching(owner, token)in the call data itself, not by RPC URL (see the note below on why). An owner with no entry resolves to0, not "unknown" — this is what makes[MO-03]-style "insufficient allowance" tests deterministic without any setup.mocks.balances.set(owner, chainId, { [token]: rawAtomString })— mocks the balances-watcher SSE endpoint (matches both the prod and.barn.hosts). Give every test in a file a default balance viabeforeEachso none of them fall back to a real, flaky balance fetch (see Known Issues below for why that fallback happens at all).- The balances-watcher path is used for every EVM chain (see
isWatcherActiveinCommonPriorityBalancesAndAllowancesUpdater.tsx) — it's no longer gated behind thebwEnabledPercentageflag, which was removed in #7985. It still falls back to a real multicall RPC read this mock cannot intercept for non-EVM chains, and transiently while the watcher is "recovering" from a prior failure — if balances stop resolving for no code reason, suspect that fallback path first.
- The balances-watcher path is used for every EVM chain (see
mocks.cowApi.set(endpointKey, override)— override any catalogued CoW Protocol API endpoint (seesrc/mocks/cowProtocolApi/endpoints.tsfor the list:quote,postOrder,accountOrders,orderStatus,order, ...).overrideis a literal body,reply(status, body), or a factory(req) => bodywherereq.defaultsis the normalized fixture default andreq.bodyis the parsed request JSON. Un-mocked CoW API URLs are blocked and fail the test at teardown — this is deliberate; usemocks.cowApi.allowUnmocked()only for a work-in-progress spec, not to paper over a real gap.- Reset/reporting (
reset(),reportUnknownOwners(),assertNoUnmatched()) all run automatically at teardown — you don't call these yourself in a spec.
Debugging failed tests
- Check for a stray dev server or RPC proxy before starting a run, especially your own from an
earlier session:
lsof -nP -iTCP:3000 -sTCP:LISTENandlsof -nP -iTCP:18545 -sTCP:LISTEN(18545 is the fixed RPC-proxy port, shared for the whole run and baked into the Synpress/MetaMask cache). If something's listening, either reuse it or find out whose it is before touching it — don't force a second instance onto the same port. playwright.config.ts'swebServer.commandispnpm start:cowswap(repo root). An earlier version usedpnpm nx serve cowswap-frontend, which silently ran through the rootpackage.json'snxalias ("nx": "npx nx run") and expanded to invalidnx runsyntax — this only surfaced in CI, wherereuseExistingServer: !process.env.CIisfalseand Playwright always launches the server itself.- Run one test at a time while iterating:
npx playwright test <file> -g "<title>". - On failure, Playwright writes a screenshot, video, and trace under
test-results/.npx playwright show-trace <path>gives you the real network/console timeline — much faster than guessing from the assertion message alone. - For "the mock seems to be ignored" style bugs, add a temporary
console.loginside the mock's override callback (mocks.cowApi.set('quote', (req) => { console.log(...); ... })). These run in the Node test process, so they print straight to your terminal, not the browser console — this was the fastest way to find every bug in this session's log (an env-var host mismatch, a stale-quote race, a fixed fee/protocolFeeBps skewing "clean" numbers, an order-progress screen with no accessible dismiss control, ...). - If a real network call unexpectedly reaches a real host, don't assume it's a code bug before checking
the environment: several failures this session traced back to the frontend dev server not having
REACT_APP_NETWORK_URL_11155111in its own environment (it's a separate process from the test runner, which has it), or a transient DNS blip in the sandbox.
Diagnosing flaky tests
A test that fails only under the full suite's parallel load, not alone or with -g, is almost
never a logic bug in the test — check infrastructure contention first.
- Reproduce the actual failure before touching anything. A single flaky test run proves
nothing either way; run the full suite (or the same worker count) a couple of times with
LOG_UNMOCKED_RPC=1 npx playwright testand look attest-results/unmocked-rpc-requests.logfor real429s before assuming a code regression. One session's evidence, captured this way:[CC-03] ... status: 429 ... url: https://mainnet.infura.io/v3/... [CC-01] ... status: 429 ... url: https://mainnet.infura.io/v3/... [CC-26] ... status: 429 ... url: https://mainnet.infura.io/v3/... - Root cause 1: a single shared real Infura key gets rate-limited under N-way parallel workers.
mocks.allowancesandinstallMulticall3deliberately fall back to a realroute.fetch()whenever a Multicall3 batch isn't fully recognized (see each one's own doc comment) — reliable for one test at a time, but every worker's fallback hits the exact same hardcoded Infura key, and enough concurrent workers trip its rate limit.logUnmockedRpcRequests.tsexists specifically to make this observable; it's disabled by default because logging every request has its own cost. (mockSocketVerifierused to be in this list too — it no longer makes any real-RPC fallback at all, see the "connected wallet's own provider" note below; a different root cause than this one.) - Closing a real-RPC-fallback gap directly beats retrying around it.
mocks/unmocked-rpc-requests.logentries are a to-do list, not just a diagnosis — each distinct(method, selector, to)still hitting a real host is a mock this suite is missing, and adding it removes a 429 source instead of just tolerating it. Example this session: anapprove(address,uint256)preflighteth_call(selector0x095ea7b3) was firing — and 429-ing — even on cross-chain tests that pre-seed sufficient allowance and never click Approve, because the wallet-connector layer simulates it unconditionally regardless of whether the UI will ever show that step.mockApproveTransaction.tsalready answered this exact selector, but only for its own specifictokenand only for tests that call it —mockApproveSimulation.tsnow answers it host-agnostically for any token/spender, registered globally in themocksfixture. Safe to match on selector alone with no token/spender scoping: an ERC20approve()succeeding is a fair default assumption, no test in this suite asserts on one reverting, and Playwright's LIFO route order means a more specific handler registered later (e.g.mockApproveTransaction's own, set up inside a test body) still wins for the token it cares about — this one only catches what nothing more specific claimed. - A multi-row UI read can tear across a re-render — read the whole snapshot atomically, not row
by row.
[CS-127]/[CS-128]each read four tooltip rows (Before costs/Protocol fee/Network costs/To) as four separately-awaitedreadRowAmount()calls, then computed a ratio from them. The swap form fires its own default-amount probe quote before the typed amount's real quote lands (same root cause as the "full wallet balance" case already noted above, just a different default-amount source) —waitForQuote()only waits for the loading flag to clear once, so if the real quote's render lands in between two of the four reads, the result is a mix of old and new state (e.g.beforeCostsfrom the stale 1-unit probe,protocolFeefrom the fresh 1000-unit quote), producing a self-consistent-looking but wrong ratio — confirmed by instrumenting the mock callback withconsole.log(prints to the Node process, not the browser) and correlating its output against the same test's row-read output via a per-run random tag, since parallel workers' console output interleaves. Fixed by moving all four reads inside a singleexpect.poll(async () => { ...four reads...; return ratio })callback, so every retry re-reads the full snapshot together instead of trusting a stale mix — the same idiom[CC-17]'s checkbox retry already uses, just applied to a read instead of a click. - A page-load default (currency or typed amount) can still be mid-resolution when the very next
UI action fires, even when the test already "types before selecting" to dodge it. Two distinct
production hooks each carry a sticky ref written specifically to survive this kind of currency/
amount change, but a ref only latches once the state it preserves has actually resolved — right
after
goto(), under CI load, that resolution can still be in flight when the next action lands, so the mitigation not being airtight isn't a logic bug in the hook, just a race it doesn't fully close.- Typed sell amount reverts to "1".
useSetupTradeAmountsFromUrl'sisAtLeastOneAmountIsSetRef.current ||= Boolean(inputCurrencyAmount || outputCurrencyAmount)only latches once the just-typed amount has been reparsed into a realinputCurrencyAmountagainst whichever token is currently selected — a synchronous, no-debounceuseMemoinuseBuildTradeDerivedState.ts, but under severe CI CPU contention even that can still not have been scheduled/rendered by the time the currency switch fires, anduseSetupTradeAmountsFromUrl's own "no amount set yet" 1-unit default wins the race. Observed as[CS-59]/[CS-118](selectTokens-driven, both sides picked) and[CS-68](a native ETH pick specifically, which has an extracrossChainFamilySwitch()microtask gap peruseOpenTokenSelectWidget.tson top of the same reparse race). First attempt that didn't actually work:await swapPage.waitForQuote()right after typing, before the switch — the theory (a quote fetch can't fire without a genuinely parsed amount, so waiting one out proves the reparse landed) is reasonable but wrong in practice, because the quote fetch itself sits behind its own, separate 350ms debounce (AMOUNT_CHANGE_DEBOUNCE_TIMEinuseQuoteParams.ts) — checking too soon after typing findsdata-isLoadingnever having been set at all yet, a false-positive "not loading"waitForFunctionhappily resolves in milliseconds. Confirmed by tracing a CI run where this added wait resolved in ~40ms and[CS-59]still lost the race identically. Actual fix: retype the same amount again once the currency switch has fully landed (selectTokens/bothsearchAndPick()calls returned) — this removes any dependency on winning the race in the first place, rather than trying to catch the exact moment it's safe to proceed. Applied to all four:[CS-59]/[CS-118]/[CS-68]/[CS-103]. - Currency reverts to "Select a token".
useNavigateOnCurrencySelection'slastKnownInputCurrencyIdRef/lastKnownOutputCurrencyIdRefexist specifically to preserve whichever side isn't being picked, but each only latches once that side's currency has resolved in React state. Picking the other side before that lands wipes the untouched side back tonullin the resulting URL/trade state instead of preserving it. Observed as[CS-104]finding no#input-currency-inputtoken at all (not a wrong value — the whole panel was blank, since the sell side had no currency selected). Fixed withSwapPage.waitForBothCurrenciesResolved()— waits until neithersellTokenSelectnorbuyTokenSelectstill showsCurrencySelectButton's "Select a token" placeholder — called right aftergoto(), before the firsttokens.open{Input,Output}()+searchAndPick(), in any test that changes one side via the picker while relying on the default for the other ([CS-68]/[CS-103]/[CS-104]). Deliberately not baked intogoto()/unlockIfNeeded()themselves:[CS-299]/[CS-301](picking a Solana/Bitcoin destination) leave one side genuinely, deliberately unresolved for a while, and would hang forever waiting on it — call the helper explicitly, only where both sides are actually expected to already have a value. - General lesson for both: don't guess at a fixed sleep for either race, and don't trust a
signal just because it's plausible that it depends on the same state — verify it's actually
causally guaranteed to, or it can resolve as a false positive before anything real happened
(
waitForQuote()'s failed attempt above: reasonable-sounding, wrong, because the thing being waited on sits behind a different debounce than the thing that actually needed to settle). Where possible, prefer removing the dependency on timing altogether over trying to catch the exact safe moment — retyping after the fact (used for the amount race) beats waiting before the fact. Where the race is about identity rather than a value (the currency race, nothing to "retype"), wait on a signal from the same render path as the ref being protected — a resolved currency selector, the same idiom the "multi-row UI read" fix above uses for a torn read. Either way, keep the wait in one named, reusable page-object method (waitForBothCurrenciesResolved) rather than duplicating an inline assertion — with its full "why" comment — at every call site that needs it; a hardcoded expected value (e.g.'Selected token: WETH') also doesn't generalize to tests using a non-default pair, where a generic "not still the placeholder" check does.
- Typed sell amount reverts to "1".
- A page-object helper that polls DOM visibility and re-sends a key/click on every tick can send
a genuinely redundant extra input once the underlying state already changed but the DOM is still
animating out — and that extra input can have real side effects elsewhere on the page.
AccountModal.close()used to beexpect.poll(async () => { if (isVisible) press('Escape'); return isVisible }). Escape flips the account modal's own open state on the first press, but its content stays visible for a bit longer through its CSS close transition — so the next poll tick, seeingactivitiesListstill visible, pressed Escape again, confirmed directly in a Playwright report showing the key sent twice. That second press has nothing left to close (the modal already closed on the first one) and instead reaches whatever else on the page is also listening for it — observed as[CS-68]losing its order-progress panel (dismissed by its own Escape-to-back handler) right after the account-modal check that runs mid-test. Before attributing a flake like this to app-level architecture (e.g. two Escape listeners racing), rule out a redundant-input bug in the test helper first — it's simpler, and a Playwright report's own step timeline (how many times an action actually fired) is hard evidence worth checking before going deep into library internals. Fix: send the input once, thenwaitForthe end state (which rides out the transition) instead of polling visibility and resending on every tick; only resend if that wait genuinely times out. - Root cause 2: the default 5s
expecttimeout is tight under CPU contention. Several known-load-sensitive assertions (the recipient-confirmation checkbox retry in[CC-17], the order-progress-modal reopen in[CS-60]) have their own comments acknowledging they only flake under concurrent test load, not in isolation — heavy parallel Chromium + one shared dev server competing for CPU cores makes debounces/polling cycles that normally settle in well under a second take long enough to blow past a tight default. - Suite-wide mitigation applied in
playwright.config.ts:expect: { timeout: 10_000 }(was the unconfigured 5s default) andretries: 1unconditionally (wasCI ? 1 : 0) — a load-induced flake should self-heal on retry rather than fail the run, locally too, not just in CI. These are mitigations for contention, not a fix for the underlying rate limit — a real429under sufficiently heavy load can still exhaust a retry. Per-assertion overrides above this floor (like[CS-60]'s existing 15s wait) are still correct and still needed for the worst offenders; don't remove them just because the global floor moved up. - Root cause 3a (real, but only part of the story):
page.goto()'s defaultwaitUntil: 'load'blocks on irrelevant third-party resources, eating into a fixed post-navigation timeout.wallet.openApp()(fixtures/mockWallet.ts) andSwapPage.goto()both callpage.locator('#web3-status-connected').waitFor({ timeout: 15_000 })right afterawait page.goto(...)returns — butgoto()'s defaultwaitUntil: 'load'only resolves once every resource on the page has finished loading, including third-party iframes the app injects for analytics (Google Tag Manager). Confirmed by instrumenting the reconnect path end to end: in one captured run, the mock wallet'sconnect()resolved in ~15ms, butpage.goto()itself didn't return control to the test for another 2.5s, waiting on GTM'sns.htmliframe. Fixed by passing{ waitUntil: 'domcontentloaded' }to bothgoto()calls. This genuinely helps but does not fix the flake on its own — it was fixed and retested, and the same "wallet not connected" timeout still reproduced under--workers=6, which is what led to root cause 3b below. - Root cause 3b (the actual cause): a startup race inside wagmi +
@reown/appkit-adapter-wagmican wipe a just-written wallet connection before the UI ever reads it — nothing to do with this suite's mocks. Reconnect logic here always succeeded (wagmi's internal store reachedstatus: 'connected'within ~100–400ms in every capture, confirmed via temporary instrumentation of@wagmi/core'sreconnect()/getConnection()), yetuseAccountState()(libs/wallet/src/wagmi/hooks/useAccountState.ts) kept reportingisConnected: true, address: undefined— soWalletStatusButtonnever rendered#web3-status-connectedand fell through to the disconnected UI onceuseIsRestoringConnection's 1s safety timeout expired. Root cause, proven with millisecond-level logs: AppKit'sWagmiAdapter.syncConnections()drives its own asyncreconnect()(writing the real connection intoconfig.state.connections), while@wagmi/core's ownhydrate()onMount— configured withreconnectOnMount: falsesince AppKit handles reconnection itself — independently runsconfig.setState(x => ({...x, connections: new Map()}))("reset connections that may have been hydrated from storage") on every mount, dozens of times in the first ~100ms of boot. These two initializations are completely uncoordinated. Normally the reset finishes beforesyncConnections()'s reconnect writes anything (harmless — nothing to reset yet). Under load, the ordering can flip: reconnect writes the real connection, then the reset fires milliseconds later and wipes it;reconnect()'s own finalstatus: 'connected'write then lands on an emptyconnectionsmap. This is why it only shows up in this suite and not for real users: every mock-wallet RPC call (eth_accounts/eth_chainId/etc.) is a genuine async round trip throughcontext.exposeBinding, unlike a real browser-extension wallet's near-synchronous response — that extra latency is what gives the reset enough of a window to interleave and win. Fixed with a durable pnpm patch, not a source edit to our own code:patches/@wagmi__core@3.4.8.patchguards wagmi's reset branch onconfig.state.status === 'disconnected'— if anything (e.g. AppKit's own reconnect) is already connecting/connected by the time this mount callback runs, skip the reset instead of clobbering it. Registered viapatchedDependenciesinpnpm-workspace.yaml(see the note below on where that key now belongs). Confirmed against 3+ consecutive--workers=6runs with zero recurrence after this patch, versus reliably reproducing every few runs before it. pnpm.patchedDependencies(andoverrides/peerDependencyRules/packageExtensions/allowBuilds) in rootpackage.jsonare silently ignored under this repo's pinnedpnpm@10.30.3. Everypnpminvocation prints "The 'pnpm' field in package.json is no longer read by pnpm" — these settings must live under a top-levelpatchedDependencies:(etc.) key inpnpm-workspace.yamlinstead. OnlypatchedDependencieshas been migrated so far (required to make the@wagmi/corepatch above actually apply —pnpm patch-commitstill writes new entries intopackage.json's deadpnpm.patchedDependenciesblock by default, which silently does nothing on install; move the entry topnpm-workspace.yamlimmediately after committing any new patch, or it won't take effect). Even after migrating, a singlepnpm install/pnpm dedupemay not converge every peer-dependency-hashed copy of a patched package innode_modules— check withfind node_modules/.pnpm -maxdepth 1 -iname '<pkg>@<version>*'and diff each copy's patched file if something still isn't taking effect.overrides/peerDependencyRules/packageExtensions/allowBuildsare still unmigrated and therefore still inert — a deliberate, separate fix, not something to migrate as a side effect of an unrelated change, since enforcing dormantoverridesfor the first time could shift other resolved versions.- Confirm a suspected regression by testing the unmodified code under the same load, not just
by re-running your changed version and seeing it pass once.
git stashthe diff, rerun the exact same failing test/suite, and only call something a regression if the clean baseline doesn't reproduce it too. This is how CC-13's "insufficient balance"/"Error loading price" failures and CS-128's flake were both confirmed pre-existing and unrelated to a same-session diff, twice.
Cross-chain bridging (cross-chain-swaps.spec.ts)
- LaunchDarkly can't be mocked via HTTP here. With no
REACT_APP_LAUNCH_DARKLY_KEYconfigured, the real LD SDK never even attempts flag-evaluation polling (only a/sdk/goals/call fires, never/sdk/evalx/...), so route-mocking its API is a dead end. InsteaduseFeatureFlags()(libs/common-hooks/src/useFeatureFlags.ts) readswindow.__COWSWAP_E2E_FEATURE_FLAGS__directly and merges it over the real (permanently unresolved) flags;mocks/launchDarkly.tssets that window property viacontext.addInitScript, andmocks.launchDarkly.setFlag(key, value)is how a spec turns onisBungeeBridgeProviderEnabled/isNearIntentsBridgeProviderEnabled/isSolBridgeEnabled/isBtcBridgeEnabled, etc. Bungee alone doesn't need this — it's added to the provider set unconditionally at module load intradingSdk/bridgingSdk.ts. - Near Intents' attestation is a real ECDSA signature check and cannot be forged.
recoverDepositAddressverifies the quote/attestation pair against Near's real attestor key — a captured fixture pair only satisfies it if replayed byte-for-byte for the exact route it was captured for.bridgingSdk.tspatchesnearIntentsBridgeProvider.recoverDepositAddressto a no-op success, gated behind the existingwindow.__COWSWAP_E2E__flag — a production-source-file edit, but scoped to e2e only. Consequently the Near fixture (mocks/bridge/fixtures/near-quote.json/near-attestation.json) can only be served verbatim for the one route it was recorded against (Mainnet USDC → Base USDC) — don't edit its numbers. BridgingSdk.getBestQuote()always fetches a regular CoW quote first (swap leg: sell token → intermediate token) and feeds that quote'sbuyAmountin as the amount the bridge provider itself quotes. The default/quotefixture's scaling is tuned for a same-decimals WETH:testUSDC pair and produces nonsense for any other pair — always pin the swap leg withmockFixedRateQuotefor a cross-chain test. When sell and intermediate-buy token decimals differ (e.g. native ETH's 18dec sell → a 6dec USDC intermediate),mockFixedRateQuote's plainsellAmount * numerator / denominatoris decimals-agnostic and silently produces an amount ~12 orders of magnitude too large (surfaces as an absurd"for at least 99.339B USDC"in the confirm modal). Overridequotea second time aftermockFixedRateQuotewith a manually decimals-adjusted ratio in that case (see[CC-13]).- The app's own real-RPC traffic for a given chain does not reliably go through
REACT_APP_NETWORK_URL_<chainId>. That env var only backs this suite's own wallet-side dispatch/proxy (walletEngine.ts→rpcProxy.ts) and the real MetaMask network config inwallet.setup.ts. Every mock inmocks//support/that intercepts a direct RPC read — receipt polls, native-balance multicalls, allowance/approve reads,eth_estimateGas— is registered host-agnostically (context.route('**/*', ...), decode the JSON-RPC body, match bymethodand the actual call data/tx hash) rather than scoped to that URL, precisely because plenty of calls the app itself makes go straight to whichever of the app's own hardcoded providers it picks (Infura, the WalletConnect RPC relay, publicnode, ...), unpredictable and outside this env var's control (seemockEthEstimateGasinmockEthFlowTransaction.tsfor the canonical example, andinstallNativeBalanceRoute/mockApproveTransaction's own receipt route for two more). Bungee's on-chain SocketVerifier check is a different case entirely — see the next note. - Bungee's on-chain SocketVerifier check (
validateRotueId/validateSocketRequest,verifyBungeeBuildTxDatain@cowprotocol/sdk-bridging) is mocked entirely bymocks/socketVerifier.ts— a standalone, host-agnosticcontext.route('**/*', ...)mock, same shape asethBlockNumber.ts/ethGetCode.ts. It decodes both a directeth_callto the SocketVerifier contract and one batched inside a Multicall3aggregate3(mirroringinstallMulticall3's own batch decoding), resolving either to a safe empty success without touching the network, and otherwise falling back untouched. It's registered ahead of bothinstallMulticall3andinstallAllowancesin themocksfixture (Playwright's route order is LIFO — last registered gets first look), so it catches the check regardless of which real RPC host the app's independent read-only client would otherwise have picked — e.g.https://ethereum-rpc.publicnode.comfor Mainnet, the same hostREACT_APP_NETWORK_URL_1configures andmocks/allowancesowns. Deliberately not folded intomocks/allowances/codec.ts: that codec is allowance-shaped (ClassifiedCall= allowance | batch | opaque) and shared withinstallMulticall3's own resolver, so adding a third mock's selector there would have coupled two unrelated concerns for no benefit — a standalone mock keeps this one deletable/testable on its own, same as every other single-purpose mock inmocks/.- History, worth keeping in mind if this check ever silently stops being mocked again: it was
first mocked with a
context.route('**/*', ...)handler modeled onmockEthEstimateGas, and that silently never matched anything under load, intermittently manifesting as[CS-287]/[CS-297]/etc. failing with "Error loading price" or a hungBridgeRoutePanel.expand(). Root cause, found by having the app log the realreadContracterror instead of swallowing it: the SDK adapter'sreadContractfor this check ran against the connected wallet's own provider (this suite's mock wallet resolves the chain from the currently-connected chain, which for these tests happens to be the bridge's origin chain — Mainnet), not the app's separate HTTP viem client — andeth_calls made through the wallet provider goinjectedShim.ts→walletEngine.ts'sdispatch()→forward(), a plain Node-sidefetch()straight to this suite's own RPC proxy (support/rpcProxy.ts), never touching the page's network layer at all. That was fixed with a dedicatedsupport/mockSocketVerifier.ts, stubbing the RPC proxy's own(to, selector)primitive (rpcProxy.stubCall(...)) directly instead of routing pages. Oncemocks/socketVerifier.tsabove existed and the suite kept passing without it,support/mockSocketVerifier.tsand itsrpcProxy.stubCallusage were deleted as redundant — so today there is exactly one SocketVerifier mock, not two. Lesson: if a mock built oncontext.route()seems to work "sometimes" for a wallet-adjacent on-chain read, check whether the call is actually reaching the wallet's own provider instead of the page's network layer before adding more retry/timeout budget around it — no amount of extra timeout fixes a mock that's listening on the wrong layer. (Whether that still applies to this check specifically, or the wallet-forwarded path simply doesn't fire for it anymore, wasn't re-diagnosed before deleting the old mock — if this check ever starts flaking again the way[CS-287]did, that wallet-provider path is the first thing to re-check before assumingmocks/socketVerifier.tsitself regressed.)
- History, worth keeping in mind if this check ever silently stops being mocked again: it was
first mocked with a
[CS-287](Bungee) was failing in CI with "no postOrder request observed" on both retries — root cause was a real, unmockedeth_callleaking to the actual internet, not CI-load slowness. Confirmed from a failing CI run's own trace (0-trace.network; Playwright records_wasFulfilled: falseplus genuine non--1dns/connect/ssltimings for a request that actually left the machine, vs_wasFulfilled: truewith-1timings for one acontext.route()mock answered): the app polls the connected wallet's native ETH balance in the background via Multicall3'sgetEthBalance(owner)(selector0x4d2301cc, batched throughaggregate3, seeclassifyEthCallinsupport/mockEthFlowTransaction.ts) regardless of what token is actually being traded — a plain USDC→USDC swap polls it just as much as a native-ETH sell. The only existing mock for this call,installNativeBalanceRoute, was wired up solely insidemockEthFlowTransaction/mockWrapTransaction/mockUnwrapTransaction— every other test in the suite (the vast majority) had no mock for it at all, so it fell through everycontext.route()mock'sroute.fallback()all the way to the realREACT_APP_NETWORK_URL_1host (ethereum-rpc.publicnode.com) roughly every 3-8 seconds for the test's entire duration, including during whatever window a test happens to be timing ([CS-287]'sexpectOrderToBePostedin this case). A real, shared-CI-runner-rate-limited dependency — same class of bug asethGetCode.ts's own documented history — explains the consistent (not marginal, not proportional to CI load) full-timeout failure on both retries far better than "the mocked flow is just slow": a full local trace of the confirm step (CPU-throttled up to 3x, and separately against apnpm previewproduction build) never took more than ~3s end to end, yet report evidence showed the entire Bungee-path chain genuinely reaching outside the mocked sandbox on a live CI run. Fix: extractedmocks/nodeRpc/ethBalance.ts— reusesmockEthFlowTransaction.ts's already- exportedclassifyEthCall/isFullyMocked/isFullyOpaqueCall/resolveEthBalanceBatchhelpers to answergetEthBalancewith a static default (1 ETH), installed unconditionally in themocksfixture (fixtures/shared.ts) for every test, the same waymockApproveSimulationwas made global after a near-identical discovery for a different call. Registered beforemockEthFlowTransaction/mockWrapTransaction/mockUnwrapTransactionrun (LIFO route order), so a test that also calls one of those still gets that mock's own dynamic, tx-tracking balance instead of this static default. Lesson: a network trace's request URL alone doesn't tell you whether a call was actually mocked —context.route()interception happens before any real socket work, so an intercepted request's logged URL looks identical to a real one; check_wasFulfilled(ordns/connect/ssltimings — genuine values vs-1) to tell them apart before concluding a mock is or isn't the gap.- A real native-ETH sell (
[CC-13], eth-flow) needseth_estimateGasstubbed too, not justeth_sendTransaction. Left unmocked, gas estimation is a real simulation against the wallet's real on-chain balance — zero on Mainnet, since this is a shared test key with no real funds (never fund it; Sepolia's equivalent test works only because that address genuinely holds real, free Sepolia ETH) — and fails with a genuine "exceeds the balance of the account" error before the stubbed send is ever reached. mockOrderPostingdoesn't work for eth-flow orders — there's nopostOrdercall to hook (the uid is computed client-side before anything is sent on-chain). Overrideorder/orderStatusmanually instead (mirrors[MO-11]). One extra step specific to bridging eth-flow orders:useSwapAndBridgeContextresolves the bridge provider fromorder.apiAdditionalInfo.fullAppData(bridgingSdk.getProviderFromAppData) — without it,bridgingStatusnever resolves and the progress modal sticks on "Executing" forever regardless of whatorder/orderStatussay. Since an eth-flow tx only carries the app-data hash on-chain (no room for the full JSON in abytes32), capture the real document via aputAppDataoverride ((req.body as { fullAppData: string }).fullAppData) and thread it into theorderoverride's ownfullAppDatafield.- "Expected to receive" and "Min. to receive" are computed completely differently for a bridge leg,
and only one of them gets rescaled to match the swap leg.
useEstimatedBridgeBuyAmountrescales the swap leg's real output through the bridge quote's own before-fee ratio, so formReceive (incl. fees), the bridge stop'sExpected to receive, and (for Bungee, whose mock scales proportionally) roughly the swap stop's own figure all end up self-consistent.Min. to receiveat the bridge stop is not rescaled — it's the bridge SDK quote's rawamountsAndCosts.afterSlippage.buyAmount, carrying that provider's own real routeFee/slippage. Don't assert equality between a swap leg's and a bridge leg'sMin. to receive— assert presence instead. For Near Intents specifically, both the quote'ssellAmountandbuyAmountcome from the same static signed fixture, so its bridge-stopMin. to receiveis an absolute number from that fixture, unrelated to whatever amount the test actually trades. - Solana availability needs two independent flags, Bitcoin needs only one.
isSolBridgeEnabled/isBtcBridgeEnabled(the LD-bypass flags above) gate chain availability inuseSupportedTargetChains, but Solana additionally needsisSolanaEnabledforCHAIN_INFOto have a Solana entry to look up at all (libs/common-const/src/featureFlags.tsexports the resolvedIS_SOLANA_ENABLEDboot flag, read once at module load — before LaunchDarkly's client exists — so it honors the samewindow.__COWSWAP_E2E_FEATURE_FLAGS__override the other LD-bypass flags above use). Set it via theinstallLaunchDarklymock'ssetFlag('isSolanaEnabled', true), applied viacontext.addInitScriptbefore navigating — same mechanism asisSolBridgeEnabled/isBtcBridgeEnabled. - Near Intents' real dest-tokens fixture has no usable exact-"BTC" entry. Its one
blockchain: "btc"token withsymbol: "BTC"(nep141:btc.omft.near) is on the SDK's own hardcoded deprecated-asset-id list and gets filtered out client-side; the only Bitcoin-chain token that survives issymbol: "BTC(OMNI)". Search/pickBTC(OMNI), notBTC. - Validation-blocking button states can render with no
idat all.TradeFormButtonsonly gives#do-trade-buttonto the "no validation errors" case; a function-component validation state (e.g.RecipientNotSet,RecipientNotConfirmedintradeButtonsMap.tsx) renders its ownTradeFormBlankButtonwith noidprop. Match those by role/text (page.getByRole('button', { name: /.../i })), not by a#do-trade-button/swapButtonlocator. - A controlled confirmation checkbox can lose a click under load.
recipientConfirmationCheckbox(#receiver-confirmation) is driven by recipient-validation state that can still be settling right after typing an address; a still-in-flight debounce can resetconfirmedback tofalseimmediately after Playwright's.check()lands, surfacing as "Clicking the checkbox did not change its state" — reproduces reliably only under concurrent test load (multiple workers), not in isolation. Retry viaexpect.poll(async () => { await checkbox.check(); return checkbox.isChecked() }).toBe(true)instead of a single.check(). - The app's HashRouter makes
page.goto()to a new#/...route a same-document navigation —bridgingSdk's available-provider set is a page-lifetime singleton seeded once at module load, so a test that switches providers mid-test (mocks.launchDarkly.setFlagagain) needs an actualpage.reload()after the new hash is already in the address bar for the switch to take effect. - The Bungee quote fixture's
output.amountis a single captured absolute number, unrelated to whatever amount a given test's sell leg actually produces —mocks/bungee.ts's/quotehandler scales every amount field (and their USD counterparts) proportionally to the liveinputAmountquery param to keep the fixture's own input:output ratio (and therefore price impact) realistic for any sell amount.
Known issues (discovered this session, unresolved)
mocks.balances.set()called after the app already has an open SSE connection (e.g. from inside apostOrderoverride, to reflect a fill) only reaches the UI via the browser's automatic EventSource reconnect (the mock fulfills one complete response and relies on aretry:-driven reconnect to serve an updated snapshot). This has been observed to work when the affected test runs first/alone, and to silently fail to update (initial balance is correct, the post-trade update never arrives) when it runs after other tests in the same file — root cause not yet confirmed. Ruled out: mock state leaking between tests (each test gets a fresh context and a freshoverridesmap).- A genuine push-based fix (local HTTP server +
node:eventsEventEmitter, redirecting the browser's request viaroute.continue({ url })) was attempted and reverted.route.continue()requires the replacement URL to keep the same protocol as the original request; redirecting anhttps://SSE request to a plain-http://local server silently fails. A working version needs the local server to speak HTTPS (self-signed cert) plusignoreHTTPSErrors: trueset globally inplaywright.config.ts. - Until one of these lands, treat post-trade balance-update assertions as best-effort: use a generous timeout, and don't be surprised if such a test is order-sensitive within a file.
- A genuine push-based fix (local HTTP server +
- The Sepolia test "USDC" token used by the committed fixtures has 18 decimals, not real USDC's 6 — don't assume decimals from the token symbol; check the fixture data.
- After confirming a trade, the currency panels hide their balance while
disabled(pending/ just-submitted —CurrencyInputPanelonly renders it when not disabled), and the flow lands on an order-progress screen rather than immediately back on the swap form. Its back arrow has no accessible name; dismiss it withpage.keyboard.press('Escape').