A swap page says that it will approve 10 USDC. MetaMask opens with a different network or an unlimited allowance for an unfamiliar contract. A page-only test still sees the “Approve” button, clicks it, and reports success. The test passed the web page while missing the security decision the user saw.
MetaMask automated testing must inspect both sides of that boundary. MetaMask is a browser and mobile wallet documented in the official developer documentation. A dapp is a web application that asks the wallet to expose an account, switch networks, sign data, or submit a transaction. The dapp sends a request through the page’s wallet provider; MetaMask presents the approval; and, if a broadcast transaction is accepted and included, the chain records it. For an allowance, the prompt must identify the token-spending limit and the spender contract receiving it.
Tangle Browser Agent drives a real Chromium session from a goal; bad is the short name of its command-line program.
Its public wallet-testing guide runs MetaMask or Rabby against a local Anvil chain using two routes.
The extension’s background RPC calls go to Anvil, while page-level RPC interception forwards only calls involving the configured test-wallet address to Anvil; protocol and pool data continue to use real RPC endpoints.
The driver records page actions and screenshots.
A useful wallet test should add the wallet prompt, provider response, and final application state so a reviewer can compare what the dapp requested, what the wallet displayed, and what happened afterward.
For the wider state model, read DeFi Wallet Testing With Browser Agents.
For the evidence record, read AI Browser Testing With Evidence Traces.
Test the wallet boundary a user sees
The word “connect” hides several separate promises. The application must request the right account access. The wallet must show the request. The page must reflect the selected account. The account can change later. The chain can change later.
| User-visible action | Application condition | Wallet evidence |
|---|---|---|
| connect | the page shows the selected test account or a clear connected state | requested account access and selected account |
| reject connection | the page stays usable and explains the rejection | wallet rejection and provider error |
| switch network | the page and wallet agree on the chain | requested and selected chain |
| sign a message | the page describes the message purpose | exact message or typed-data prompt |
| approve an allowance | the page names the token and spender | allowance amount, token, and spender |
| submit a transaction | pending, success, and failure states are distinct | transaction details and outcome |
| change account | account-specific data refreshes or the session closes | accountsChanged event and new page state |
The test should save the page screenshot immediately before the prompt and the wallet screenshot at the decision. The first shows the context the user had. The second shows the context the wallet rendered. The final page shows whether the application reported the result honestly.
Use the public provider interface
An EIP, or Ethereum Improvement Proposal, is a public specification for an Ethereum interface or protocol change. EIP-1193 defines a common JavaScript provider API, or application programming interface, for Ethereum applications and wallets. The provider exposes a request method for wallet and chain calls. RPC means remote procedure call, the request-and-response interface used to ask the wallet or chain client for state and actions. EIP-1193 also defines events for connection, disconnection, chain changes, and account changes.
Those events are part of the test surface. An app that never handles accountsChanged can keep showing balances for the previous account. An app that ignores chainChanged can display a quote for one chain while the wallet signs on another. An app that treats a rejected request as success can leave a false “Connected” or “Submitted” state behind.
EIP-1193 recommends provider error code 4001 for user rejection. It recommends 4100 for unauthorized requests, 4200 for unsupported methods, 4900 for a disconnected provider, and 4901 when the requested chain is unavailable while another chain remains connected. Record the code when the app’s recovery behavior matters.
An illustrative browser-side observer looks like this:
type ProviderError = Error & { code?: number; data?: unknown }
const provider = window.ethereum
if (!provider) {
throw new Error('MetaMask or another injected provider is unavailable')
}
provider.on('accountsChanged', (accounts: string[]) => {
console.log({ event: 'accountsChanged', accounts })
})
provider.on('chainChanged', (chainId: string) => {
console.log({ event: 'chainChanged', chainId })
})
try {
const accounts = await provider.request({ method: 'eth_accounts' })
const chainId = await provider.request({ method: 'eth_chainId' })
console.log({ accounts, chainId })
} catch (error) {
const providerError = error as ProviderError
console.error({
message: providerError.message,
code: providerError.code,
data: providerError.data,
})
}
This is an illustrative EIP-1193 example. MetaMask Connect is MetaMask’s current connection software development kit, or SDK, for connecting supported wallet environments to applications. An application that uses MetaMask Connect may use that SDK rather than reading an injected provider directly. The MetaMask Connect documentation says the product can select a desktop extension, QR-code, or mobile-app connection method based on the user’s environment. The test should assert the connection behavior that the application supports instead of assuming the extension is always present.
Make signing a data test
A signing test should inspect the exact message, typed-data domain, spender, value, destination, and chain shown in the prompt. Personal signing presents a message. Typed signing presents structured fields. Transaction signing presents a destination, value, data, and gas-related context according to the wallet and network.
EIP-712 defines a standard for hashing and signing typed structured data. It includes typed fields and a domain separator so a wallet can present more meaningful context than an opaque byte string. The standard does not provide replay protection. In this context, a nonce is a number used once to prevent a signed operation from being reused or applied out of order. An application still needs a nonce, expiry, chain binding, or another policy appropriate to its operation.
An illustrative typed-data fixture can make the intended prompt reviewable:
{
"domain": {
"name": "Example Exchange",
"version": "1",
"chainId": 11155111,
"verifyingContract": "0x0000000000000000000000000000000000000001"
},
"types": {
"EIP712Domain": [
{ "name": "name", "type": "string" },
{ "name": "version", "type": "string" },
{ "name": "chainId", "type": "uint256" },
{ "name": "verifyingContract", "type": "address" }
],
"Permit": [
{ "name": "owner", "type": "address" },
{ "name": "spender", "type": "address" },
{ "name": "value", "type": "uint256" },
{ "name": "nonce", "type": "uint256" },
{ "name": "deadline", "type": "uint256" }
]
},
"primaryType": "Permit",
"message": {
"owner": "0x0000000000000000000000000000000000000002",
"spender": "0x0000000000000000000000000000000000000003",
"value": "1000000",
"nonce": "0",
"deadline": "2000000000"
}
}
The addresses and numbers above are illustrative placeholders. The test should compare the prompt to the expected fixture before a controlled test wallet approves it. It should run the rejection path as well. The application must not record a session, allowance, or completed action when the user rejects the request.
The provider request and the wallet screenshot prove different things.
The request shows the structured data the dapp handed to MetaMask; the screenshot shows what MetaMask rendered for the user.
Save the intercepted eth_signTypedData_v4 request as captured-wallet-request.json and the fixture above as expected-permit.json, then make the field comparison explicit:
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
type TypedDataRequest = {
method: 'eth_signTypedData_v4'
params: [string, string]
}
const captured = JSON.parse(
await readFile('captured-wallet-request.json', 'utf8'),
) as TypedDataRequest
const expected = JSON.parse(
await readFile('expected-permit.json', 'utf8'),
)
const actual = JSON.parse(captured.params[1])
assert.equal(captured.method, 'eth_signTypedData_v4')
function assertTypedDataMatches(
actualData: typeof expected,
expectedData: typeof expected,
) {
assert.deepEqual(actualData.domain, expectedData.domain)
assert.deepEqual(actualData.types, expectedData.types)
assert.equal(actualData.primaryType, expectedData.primaryType)
assert.deepEqual(actualData.message, expectedData.message)
}
assertTypedDataMatches(actual, expected)
const wrongSchema = structuredClone(expected)
wrongSchema.domain.name = 'Lookalike Exchange'
wrongSchema.types.Permit[1].type = 'bytes32'
assert.throws(() => assertTypedDataMatches(actual, wrongSchema))
const wrongSpender = structuredClone(expected)
wrongSpender.message.spender =
'0x0000000000000000000000000000000000000004'
assert.throws(() => assertTypedDataMatches(actual, wrongSpender))
The two negative fixtures change the expected domain and signing schema, then the spender. They prove that the comparison rejects both a lookalike prompt shape and a prompt for the wrong contract. This Node assertion is independent of the browser driver. Adapt the capture step to the application’s test setup, and keep the prompt screenshot beside the JSON because a correct request can still be presented poorly.
Build a repeatable MetaMask fixture
The fixture is the state prepared before the browser opens. Record wallet and browser versions, permissions, balances, nonce state, and application data so a rerun can isolate fixture drift.
| Fixture | Required control |
|---|---|
| extension | known MetaMask version installed and enabled before the run |
| account | disposable address with test funds and no production authority |
| network | expected chain configured before the dapp opens |
| permissions | clean connection permissions between cases where needed |
| balances | reset token and native-currency balances |
| nonce | deterministic or isolated chain state |
| app data | predictable account, quote, and session records |
| browser | recorded browser version, viewport, locale, and extension set |
Local chains are useful for repeatable transaction results. A local Ethereum development chain can provide repeatable transaction results, but the exact chain tooling belongs to the application’s test setup. Live-network checks can reveal integration issues that a local chain hides. Keep those checks few, explicit, and funded only with disposable value.
The test should record the extension version with the browser version. A wallet UI can change independently of the dapp. When prompt identification fails, the team needs to know whether the page changed, the extension changed, or the fixture did not reach the expected state.
Stop at the signing prompt
The public Browser Agent manifest names the scoped package, bad binary, and safe discovery commands. The public driver README documents the wallet and DeFi commands; the linked wallet guide covers the full setup.
npm install -g @tangle-network/browser-agent-driver
npx playwright install chromium
bad --help
bad run \
--url https://app.example.com \
--goal "Connect the disposable MetaMask account, verify the configured test chain, open the swap preview, capture the wallet prompt, and stop before signing" \
--wallet \
--extension ./extensions/metamask \
--user-data-dir ./.agent-wallet-profile \
--wallet-preflight \
--wallet-chain-id 31337 \
--wallet-chain-rpc-url http://127.0.0.1:8545 \
--no-headless
The example deliberately stops before an irreversible action.
A controlled transaction case needs a separate goal, a disposable wallet, a local or test chain, and an explicit approval policy.
In that separate case, Browser Agent’s wallet popup handler performs the approval only when --wallet-auto-approve is enabled; the test must then wait for the transaction receipt or a defined failure state before it can pass.
Do not place seed phrases or private keys in the goal text.
The DOM, or Document Object Model, is the page’s structured tree of elements and attributes. The driver’s README documents the run’s observation modes, including DOM, vision, and hybrid approaches. Vision means taking rendered screenshots as model input. Hybrid combines the two. Wallet prompts and visual chain details often need screenshots even when page controls are easy to find in the DOM.
Give rejection its own test case
A suite that covers only successful approvals misses an important product path: the user’s rejection.
| Case | Expected result | Evidence |
|---|---|---|
| reject account access | page remains disconnected and offers retry | prompt rejection, provider error, page state |
| reject chain switch | page explains the required chain and does not submit | requested chain, selected chain, error state |
| reject personal message | no session or authorization is recorded | message prompt, 4001 error, app state |
| reject typed data | no allowance or permission is claimed | typed-data prompt, rejection, app state |
| reject transaction | pending state is cleared or marked rejected | transaction prompt, error, retry state |
| close prompt | page does not assume approval | prompt closed, timeout, stop reason |
A false pass often comes from stale state. The application displays a connected account from its own memory after the wallet has disconnected. The UI shows “Submitted” after a request was sent but before a receipt or chain confirmation exists. The test should preserve the boundary between requested, signed, broadcast, pending, confirmed, and failed.
Split fast wallet smoke checks from deep runs
Wallet suites are expensive to debug because they depend on browser, extension, account, network, and data state. Use a small smoke case for every change that touches connection code. Reserve the deeper matrix for releases or changes that affect signing, allowances, transaction construction, or chain handling.
| Suite | Example cases | Required reset |
|---|---|---|
| connection smoke | connect, reject, account change | clean permissions and disposable account |
| network smoke | wrong chain, switch, chain change | known chain configuration |
| signing suite | personal message, typed data, reject | fresh nonce and expected domain |
| transaction suite | approval, submit, pending, revert | isolated balances and local or test chain |
| release replay | full dapp journey with prompt screenshots | versioned browser, extension, and fixture |
Keep the smoke case short enough to diagnose from one trace. Run the deeper suite when a release changes the parts of the flow that the smoke case cannot reach. Run the connection smoke case on each relevant change, and reserve the deeper matrix for changes to signing, allowances, or transaction submission.
Diagnose the first wrong boundary
A trace is the ordered record of the browser, wallet, provider, and chain observations for one run. Compare account, chain, prompt fields, provider errors, transaction hash, and receipt in timestamp order.
| First divergence | Likely owner | Evidence to inspect |
|---|---|---|
| app account differs from wallet account | dapp state or connection layer | accountsChanged event and both screenshots |
| requested chain differs from selected chain | network configuration | chainChanged event and wallet network |
| prompt spender differs from expected contract | transaction builder or route | typed data or transaction request |
| rejection leaves success state | error handling | provider code and app transition |
| pending never resolves | RPC, indexer, or receipt handling | request error, transaction hash (identifier), and timing |
| prompt cannot be reached | extension fixture or browser version | extension state, permissions, and screenshot |
The evaluation compares the expected account, chain, action, and outcome with that trace. Keep a failed run failed when the evidence contradicts the goal. Keep it inconclusive when the prompt or final chain result was never observable.
Keep the fixture separate from the verdict
Browser Agent drives the page and records the wallet boundary. MetaMask remains the wallet that presents account, chain, message, allowance, and transaction details. The driver can make those states easier to inspect, but it cannot decide whether a contract or an economic outcome is safe.
If a team packages the check as a service, return the page evidence, wallet evidence, and chain result separately. Report browser state, wallet evidence, transaction hash, and receipt as separate verdict fields.
The limit of MetaMask automated testing
MetaMask automated testing can provide evidence that a dapp and a particular extension fixture presented a defined wallet flow. It does not prove the smart contract is safe. It does not prove the quoted exchange rate will remain available. It does not prove a transaction’s economic outcome is correct. It does not cover every MetaMask platform, mobile path, browser version, network, account, or wallet setting. It does not make a production wallet appropriate for unattended automation.
Use simulation to inspect calldata, a receipt to establish settlement, contract tests for invariants, and manual review for high-value signing. Treat a wallet prompt screenshot as evidence of what the user was asked to approve. Treat a chain receipt or application-owned confirmation as evidence of what happened afterward.
Make the prompt reviewable
Automate MetaMask flows when connection, signing, or transaction approval can change onboarding, revenue, or user trust. Keep the account, chain, prompt contents, rejection behavior, and final state visible in the trace. Do not accept a pass that proves only that a button was clicked. For screenshots, action events, stop reasons, and bounded recovery around those wallet states, read browser automation and the AI evidence loop.