Network Interception
Tapsmith provides Playwright-style network interception for mobile apps. You can mock API responses, abort requests, modify traffic in flight, and wait for specific network activity – all from your test code. Network requests also appear in the trace viewer when capture is enabled. Traffic that cannot be decrypted can be tunneled instead: either by configuring networkPassthroughHosts, or automatically for HTTP/2-capable clients that reject Tapsmith’s generated certificate.
This guide covers two related features:
- Network interception – route handlers that let you mock, modify, or block HTTP/HTTPS requests the app makes.
- Network capture – passive recording of all network traffic into traces for post-test inspection.
Both features share the same underlying MITM proxy infrastructure.
Prerequisites
Section titled “Prerequisites”Network interception runs on Tapsmith’s MITM proxy, and mocking needs no config. When any test file of the run calls device.route(), device.waitForRequest(), device.waitForResponse() or device.on('request' | 'response'), the test runner starts the proxy as each device session starts, before the app is launched, so the connections the app opens go through it. A run that never calls them starts no proxy. The proxy then stays up for the rest of the session — drained after every test and released when the session ends — and every test starts capture at its beginning, as a trace does.
Tapsmith finds those calls by reading the run’s test files and the local modules they import (helpers, fixtures, screen objects), before any test runs. In UI mode, watch mode and the MCP server, every test file the session can run counts, as it is when each run starts: the first test that mocks, written mid-session, starts the proxy at the beginning of its file. The app is already running by then, so connections it opened in earlier runs can bypass the proxy until the app is relaunched; restarting watch or UI mode starts the proxy before the app launches. The MCP server launches the app when it connects to a device, before any test is run, so there the proxy starts at the beginning of the first test file a run_tests call runs, and requests the app sent before then — while an agent drove it with the device tools, say — can bypass it the same way. A call it cannot see — one made inside an npm package, or through a computed method name — still works: the proxy then starts on demand, at the first such call a test (or one of its hooks or fixtures) makes on a device. A test whose trace records network traffic starts the proxy at the beginning of the test too (see Network Capture in Traces).
The startup Config line says which applies: network: captured when the trace records network for every test, network: at session start when the run’s tests use the calls above, network: on demand otherwise.
Register routes before the app sends the requests they mock. A route only sees the requests made after it is registered. Register routes (and start waits) at the top of the test, or in beforeEach/beforeAll, before navigating to the screen that fetches, as you would with Playwright’s page.route() before page.goto(). The proxy only sees connections the app opens after it starts. When it starts on demand instead, mid-session, the app may already hold a keep-alive connection to that server from an earlier request, and keeps using it — past the proxy — until the app is relaunched. On an iOS simulator, the Network Extension also takes up to two seconds to pick up an app process that was just launched, so a request the app sends in its first moments after a launch can go around the proxy the same way.
When a test fails and its routes were live but none of them fired, Tapsmith names the test and lists the possible reasons the capture shows: device.route() handlers never fired: "…" registered route handlers and none of them fired. Possible reasons: …. They are:
- HTTPS to
<hosts>passed through the network proxy uncaptured — the device cannot trust Tapsmith’s CA (an Android emulator on a Google Play system image, see Android emulator image requirements), the host is intrace.networkPassthroughHosts, or the app rejected Tapsmith’s certificate. Hosts that only OS services passed through are not listed; - the network proxy started on demand during this test or its hooks — the on-demand case above;
- the network proxy started at the beginning of this file, after the app was already running — the mid-session cases above (watch, UI mode, the MCP server); relaunching the app, or restarting watch or UI mode, lets its connections go through the proxy;
- the app never sent a request matching the route patterns — check the pattern.
A waitForRequest()/waitForResponse() that times out says when no capture was running on the device, or when the proxy only started with that wait. If the proxy cannot start (on iOS, for example, the Network Extension is not approved yet — see iOS Network Capture), device.route() throws Network capture disabled: … with the reason, and a waiter rejects at once with it. A failed start is tried again once per test, not on every call.
Outside the test runner — a script that drives a Device itself — nothing starts the proxy, so route handlers never fire; at the end of a test that registered routes while no proxy was running, the runner prints a warning naming it.
To record traffic in traces as well, enable tracing in your config — any mode that records the test’s first attempt ("on", "retain-on-failure", "retain-on-first-failure", "retain-on-failure-and-retries") captures every test; "on-first-retry" and "on-all-retries" capture only retries (a first attempt still mocks through the proxy started with the session):
import { defineConfig } from "tapsmith"
export default defineConfig({ trace: { mode: "on", network: true, // default when tracing is on },})Or from the CLI:
npx tapsmith test --trace onThe --trace on flag enables tracing with all defaults, including network: true. To keep network traffic out of traces while keeping other trace features, set network: false in the config’s object form (mocking still works: the proxy starts as Prerequisites describes):
export default defineConfig({ trace: { mode: 'on', network: false },})A --trace <mode> flag replaces the config’s whole trace value, network included, so keep the mode in the config for this.
HTTP/2 traffic is intercepted when the client accepts Tapsmith’s MITM CA — including gRPC, and including Firestore on Android. Clients that use embedded roots or certificate pinning reject external MITM certificates (Firestore on iOS always does); Tapsmith detects that rejection for HTTP/2-capable clients and tunnels later connections so the app keeps working, but route handlers and waiters cannot see the encrypted requests inside. See HTTP/2, gRPC, and passthrough connections.
Intercepting Requests with device.route()
Section titled “Intercepting Requests with device.route()”device.route(url, handler, options?)
Section titled “device.route(url, handler, options?)”Register a route handler that intercepts requests matching a URL pattern. The handler receives a Route object and must call one of route.fulfill(), route.continue(), route.abort(), or route.fetch() to resolve the request.
Parameters:
| Parameter | Type | Description |
|---|---|---|
url |
string | RegExp | ((url: URL) => boolean) |
URL pattern to match |
handler |
(route: Route) => Promise<void> | void |
Handler function |
options.times |
number |
Auto-remove after N matches |
URL pattern forms
Section titled “URL pattern forms”Glob string – the most common form. ** matches any path segment, * matches within a segment.
// Match any request to the /api/posts endpointawait device.route("**/api/posts", async (route) => { await route.fulfill({ json: [] })})
// Match a specific hostawait device.route("https://api.example.com/users/*", async (route) => { await route.fulfill({ json: { id: 1, name: "Test User" } })})
// Match any request containing "graphql" in the pathawait device.route("**/graphql", async (route) => { await route.fulfill({ json: { data: { viewer: null } } })})RegExp – for complex matching patterns.
// Match versioned API endpointsawait device.route(/\/api\/v[12]\/users/, async (route) => { await route.fulfill({ json: [] })})
// Match requests with specific query parametersawait device.route(/\/search\?.*q=/, async (route) => { await route.fulfill({ json: { results: [] } })})Predicate function – receives a URL object for full programmatic control.
// Match POST requests to any API endpointawait device.route( (url) => url.pathname.startsWith("/api/") && url.hostname === "example.com", async (route) => { if (route.request().method === "POST") { await route.fulfill({ status: 201, json: { created: true } }) } else { await route.continue() } },)Auto-removal with times
Section titled “Auto-removal with times”Limit how many requests a handler intercepts before it is automatically removed:
// Only mock the first request, then let subsequent ones throughawait device.route("**/api/config", async (route) => { await route.fulfill({ json: { featureFlag: true } })}, { times: 1 })Route Actions
Section titled “Route Actions”Every route handler receives a Route object. You must resolve the route by calling exactly one of these methods. If the handler returns without resolving, the request is continued upstream automatically (with a warning logged).
route.request()
Section titled “route.request()”Returns the intercepted TapsmithRequest with properties: method, url, headers, postData, isHttps.
await device.route("**/api/**", async (route) => { const request = route.request() console.log(`${request.method} ${request.url}`) console.log("Headers:", request.headers) if (request.postData) { console.log("Body:", request.postData.toString()) } await route.continue()})route.fulfill(options?)
Section titled “route.fulfill(options?)”Return a mock response without contacting the server. This is the primary method for mocking API responses.
| Option | Type | Default | Description |
|---|---|---|---|
status |
number |
200 |
HTTP status code |
headers |
Record<string, string> |
{} |
Response headers |
body |
string | Buffer |
empty | Response body |
contentType |
string |
auto | Content-Type header |
json |
unknown |
– | JSON-serializes and sets content-type to application/json |
path |
string |
– | Read the response body from a file |
JSON mocking (most common):
await device.route("**/api/posts", async (route) => { await route.fulfill({ json: [ { id: 1, title: "First Post", author: "Alice" }, { id: 2, title: "Second Post", author: "Bob" }, ], })})Custom status codes:
await device.route("**/api/protected", async (route) => { await route.fulfill({ status: 401, json: { error: "Unauthorized", message: "Token expired" }, })})Response from a fixture file:
await device.route("**/api/products", async (route) => { await route.fulfill({ path: "./fixtures/products.json", contentType: "application/json", })})Custom headers:
await device.route("**/api/data", async (route) => { await route.fulfill({ status: 200, headers: { "Cache-Control": "no-cache", "X-Request-Id": "test-123", }, json: { data: "value" }, })})route.abort(errorCode?)
Section titled “route.abort(errorCode?)”Abort the request, simulating a network failure. The app sees the request fail as if the network were unreachable.
| Error Code | Effect |
|---|---|
| (none) | Generic abort |
"connectionrefused" |
Connection refused |
"connectionreset" |
Connection reset |
"timedout" |
Request timed out |
// Simulate a network timeoutawait device.route("**/api/slow-endpoint", async (route) => { await route.abort("timedout")})
// Block all analytics requestsawait device.route("**/analytics/**", async (route) => { await route.abort()})
// Simulate server unreachableawait device.route("**/api/health", async (route) => { await route.abort("connectionrefused")})route.continue(overrides?)
Section titled “route.continue(overrides?)”Let the request proceed to the server with optional modifications. Use this to change the request before it reaches the backend.
| Override | Type | Description |
|---|---|---|
url |
string |
Override the request URL. Supports same-origin paths and cross-origin redirection. |
method |
string |
Override the HTTP method |
headers |
Record<string, string> |
Override request headers |
postData |
string | Buffer |
Override the request body |
Same-origin path change:
// Redirect v1 API calls to v2await device.route("**/api/v1/**", async (route) => { const url = route.request().url.replace("/api/v1/", "/api/v2/") await route.continue({ url })})Cross-origin redirection (the Host header is auto-updated):
// Redirect production API calls to a staging serverawait device.route("https://api.example.com/**", async (route) => { const url = route.request().url.replace( "https://api.example.com", "https://staging.example.com", ) await route.continue({ url })})Adding an auth header:
await device.route("**/api/**", async (route) => { await route.continue({ headers: { ...route.request().headers, "Authorization": "Bearer test-token-123", }, })})Modifying the request body:
await device.route("**/api/orders", async (route) => { const request = route.request() if (request.method === "POST" && request.postData) { const body = JSON.parse(request.postData.toString()) body.testMode = true await route.continue({ postData: JSON.stringify(body) }) } else { await route.continue() }})route.fetch(overrides?)
Section titled “route.fetch(overrides?)”Fetch the real response from the server, then inspect or modify it before fulfilling. This is the “modify response” pattern – the most powerful interception mode.
route.fetch() accepts the same overrides as route.continue() (url, method, headers, postData). It returns a FetchedAPIResponse with:
| Property / Method | Type | Description |
|---|---|---|
status |
number |
HTTP status code |
headers |
Record<string, string> |
Response headers |
body() |
Buffer |
Raw response body |
text() |
string |
Body as UTF-8 string |
json() |
unknown |
Body parsed as JSON |
After calling route.fetch(), you must call route.fulfill() to deliver the (potentially modified) response. You cannot call route.abort(), route.continue(), or route.fetch() again after fetching.
Modify a JSON response:
await device.route("**/api/users/me", async (route) => { // Get the real response const response = await route.fetch() const user = response.json() as { name: string; email: string; plan: string }
// Modify it user.plan = "premium" user.name = "Test User"
// Return the modified response await route.fulfill({ json: user })})Add a field to every API response:
await device.route("**/api/**", async (route) => { const response = await route.fetch() const data = response.json() as Record<string, unknown>
// Inject a test flag data._testEnvironment = true
await route.fulfill({ status: response.status, headers: response.headers, json: data, })})Remove sensitive fields from a response:
await device.route("**/api/users/*", async (route) => { const response = await route.fetch() const user = response.json() as Record<string, unknown>
delete user.ssn delete user.creditCard
await route.fulfill({ json: user })})Fetch from a different server and return its response:
await device.route("**/api/feature-flags", async (route) => { // Fetch from the staging environment instead const response = await route.fetch({ url: "https://staging.example.com/api/feature-flags", })
await route.fulfill({ status: response.status, headers: response.headers, body: response.body(), })})Removing Route Handlers
Section titled “Removing Route Handlers”device.unroute(url, handler?)
Section titled “device.unroute(url, handler?)”Remove a previously registered route handler. If handler is omitted, all handlers for that URL pattern are removed.
const handler = async (route: Route) => { await route.fulfill({ json: { mocked: true } })}
// Registerawait device.route("**/api/data", handler)
// Remove this specific handlerawait device.unroute("**/api/data", handler)
// Or remove all handlers for this patternawait device.unroute("**/api/data")device.unrouteAll()
Section titled “device.unrouteAll()”Remove all registered route handlers at once.
await device.unrouteAll()Route lifetime
Section titled “Route lifetime”You rarely need to remove routes yourself. Tapsmith ties each route to the scope that registered it, the way Playwright ties a route to the page or context it was added to:
- A route registered in a test, in
beforeEachorafterEach, or in a test-scoped fixture is removed when that test ends. - A route registered in
beforeAllstays for every test in itsdescribeblock (or, at the top level, the whole file), including nesteddescribeblocks and retries, and is removed when that block ends, after itsafterAllhooks. A route registered inafterAllis removed at the same point. - A route registered in a worker-scoped fixture stays for the whole file and is removed when the file ends.
device.on('request' | 'response') listeners follow the same lifetimes.
import { test, describe, beforeAll } from "tapsmith"
describe("with a mocked feed", () => { beforeAll(async ({ device }) => { // Applies to both tests below. await device.route("**/api/items", async (route) => { await route.fulfill({ json: [{ id: 1, name: "Mocked" }] }) }) })
test("shows the mocked item", async ({ device }) => { // ... })
test("still mocked in the next test", async ({ device }) => { // ... })})
test("real list", async ({ device }) => { // Outside the describe block: no route handler, requests go to the real server. // ...})device.unroute() and device.unrouteAll() remove any route, whichever scope registered it, so an afterEach(() => device.unrouteAll()) also removes routes from beforeAll.
Waiting for Network Events
Section titled “Waiting for Network Events”These methods let you wait for specific network activity without intercepting it. Useful for asserting that the app made the right API calls.
device.waitForRequest(urlOrPredicate, options?)
Section titled “device.waitForRequest(urlOrPredicate, options?)”Wait for an outgoing request matching the pattern. Returns a TapsmithRequest.
Parameters:
| Parameter | Type | Description |
|---|---|---|
urlOrPredicate |
string | RegExp | ((request: TapsmithRequest) => boolean) |
URL pattern or predicate |
options.timeout |
number |
Timeout in ms (default: device timeout, 30s) |
// Wait for a request by globconst request = await device.waitForRequest("**/api/orders")expect(request.method).toBe("POST")
// Wait for a request by RegExpconst req = await device.waitForRequest(/\/api\/v\d+\/users/)console.log(req.url)
// Wait for a request matching a predicateconst createReq = await device.waitForRequest( (req) => req.url.includes("/api/orders") && req.method === "POST",)const body = JSON.parse(createReq.postData!.toString())expect(body.items).toHaveLength(2)device.waitForResponse(urlOrPredicate, options?)
Section titled “device.waitForResponse(urlOrPredicate, options?)”Wait for a response matching the pattern. Returns a NetworkResponseEventData with method, url, status, headers, body, and routeAction. The body is decoded: chunked framing is removed and gzip, deflate or br compression is undone, while headers keep the values the server sent.
// Wait for a successful responseconst response = await device.waitForResponse("**/api/orders")expect(response.status).toBe(200)
// Wait for a specific status codeconst errorResponse = await device.waitForResponse( (resp) => resp.url.includes("/api/login") && resp.status === 401,)
// Parse the response bodyconst resp = await device.waitForResponse("**/api/users/me")const user = JSON.parse(resp.body!.toString())expect(user.name).toBe("Alice")Combining waits with actions
Section titled “Combining waits with actions”The typical pattern is to start waiting before triggering the action that causes the network request:
test("submitting the form creates an order", async ({ device }) => { // Start waiting BEFORE the tap that triggers the request const requestPromise = device.waitForRequest("**/api/orders")
// Trigger the request await device.getByRole("button", { name: "Place Order" }).tap()
// Now await the request const request = await requestPromise expect(request.method).toBe("POST")
const body = JSON.parse(request.postData!.toString()) expect(body.items).toHaveLength(3) expect(body.total).toBe(29.97)})If the tap fails, the test ends before await requestPromise. The waiter ends with the test, so it can’t time out later and fail a different test. Each waiter belongs to the test that created it.
Subscribing to Network Events
Section titled “Subscribing to Network Events”For continuous monitoring (rather than waiting for a single event), use device.on() and device.off().
device.on('request', handler) / device.on('response', handler)
Section titled “device.on('request', handler) / device.on('response', handler)”Subscribe to all request or response events.
// Log all requests during a testconst requests: string[] = []device.on("request", (req) => { requests.push(`${req.method} ${req.url}`)})
// ... run test actions ...
console.log("Requests made:", requests)device.off('request', handler) / device.off('response', handler)
Section titled “device.off('request', handler) / device.off('response', handler)”Unsubscribe from events. Pass the same function reference used with device.on().
const handler = (req: TapsmithRequest) => { console.log(req.url)}
device.on("request", handler)// ... later ...device.off("request", handler)Practical Patterns
Section titled “Practical Patterns”Mocking an API endpoint
Section titled “Mocking an API endpoint”A complete test that mocks a list API and verifies the UI renders the mock data:
import { test, expect } from "tapsmith"
test("displays mocked product list", async ({ device }) => { // Mock the API before the screen loads the data await device.route("**/api/products", async (route) => { await route.fulfill({ json: [ { id: 1, name: "Widget", price: 9.99, inStock: true }, { id: 2, name: "Gadget", price: 19.99, inStock: false }, ], }) })
// Navigate to the screen that fetches products await device.openDeepLink("myapp://products")
// Verify the mocked data renders await expect(device.getByText("Widget")).toBeVisible() await expect(device.getByText("$9.99")).toBeVisible() await expect(device.getByText("Gadget")).toBeVisible() await expect(device.getByText("Out of Stock")).toBeVisible()})Testing error states
Section titled “Testing error states”Mock server errors and network failures to verify the app handles them correctly:
import { test, expect } from "tapsmith"
test("shows error screen on server failure", async ({ device }) => { await device.route("**/api/products", async (route) => { await route.fulfill({ status: 500, json: { error: "Internal Server Error" }, }) })
await device.openDeepLink("myapp://products") await expect(device.getByText("Something went wrong")).toBeVisible() await expect(device.getByRole("button", { name: "Retry" })).toBeVisible()})
test("shows offline banner on network failure", async ({ device }) => { await device.route("**/api/**", async (route) => { await route.abort("connectionrefused") })
await device.openDeepLink("myapp://dashboard") await expect(device.getByText("No internet connection")).toBeVisible()})
test("handles timeout gracefully", async ({ device }) => { await device.route("**/api/slow", async (route) => { await route.abort("timedout") })
await device.getByRole("button", { name: "Load Data" }).tap() await expect(device.getByText("Request timed out")).toBeVisible()})Modifying responses
Section titled “Modifying responses”Inject edge cases into real API responses to test boundary conditions:
import { test, expect } from "tapsmith"
test("handles empty list from API", async ({ device }) => { await device.route("**/api/notifications", async (route) => { // Fetch the real response, then replace the items with an empty array const response = await route.fetch() const data = response.json() as { items: unknown[]; total: number } data.items = [] data.total = 0 await route.fulfill({ json: data }) })
await device.openDeepLink("myapp://notifications") await expect(device.getByText("No notifications yet")).toBeVisible()})
test("handles extremely long names", async ({ device }) => { await device.route("**/api/users/me", async (route) => { const response = await route.fetch() const user = response.json() as { displayName: string } user.displayName = "A".repeat(200) // stress-test the UI with a very long name await route.fulfill({ json: user }) })
await device.openDeepLink("myapp://profile") // Verify the UI doesn't break -- name should be truncated or wrapped await expect(device.getByTestId("profile-name")).toBeVisible()})Asserting on network traffic
Section titled “Asserting on network traffic”Verify the app sends the correct requests with the right payloads:
import { test, expect } from "tapsmith"
test("search sends the right query parameters", async ({ device }) => { const requestPromise = device.waitForRequest("**/api/search**")
await device.getByPlaceholder("Search products").type("wireless headphones") await device.getByRole("button", { name: "Search" }).tap()
const request = await requestPromise expect(request.method).toBe("GET") expect(request.url).toContain("q=wireless+headphones")})
test("checkout sends correct order payload", async ({ device }) => { const requestPromise = device.waitForRequest( (req) => req.url.includes("/api/orders") && req.method === "POST", )
await device.getByRole("button", { name: "Place Order" }).tap()
const request = await requestPromise const body = JSON.parse(request.postData!.toString()) expect(body.items).toHaveLength(2) expect(body.currency).toBe("USD")})
test("login sends credentials to the right endpoint", async ({ device }) => { const [request, response] = await Promise.all([ device.waitForRequest("**/api/auth/login"), device.waitForResponse("**/api/auth/login"), // Trigger the login device.getByRole("button", { name: "Sign In" }).tap(), ])
expect(request.method).toBe("POST") expect(request.isHttps).toBe(true) expect(response.status).toBe(200)})Mocking once, then letting through
Section titled “Mocking once, then letting through”Use times to mock only the first request (e.g., show cached data on initial load, then real data on refresh):
import { test, expect } from "tapsmith"
test("pull-to-refresh fetches fresh data", async ({ device }) => { // First load returns stale data await device.route("**/api/feed", async (route) => { await route.fulfill({ json: [{ id: 1, title: "Cached Item" }], }) }, { times: 1 })
await device.openDeepLink("myapp://feed") await expect(device.getByText("Cached Item")).toBeVisible()
// Pull-to-refresh hits the real server (route was auto-removed after 1 match) await device.swipe("down") // The real server's response will render now})Network Capture in Traces
Section titled “Network Capture in Traces”When tracing is enabled with network: true, all HTTP/HTTPS traffic from the device is recorded in the trace archive. This is separate from route interception – capture records passively, while interception lets you modify traffic.
How capture works
Section titled “How capture works”The Rust daemon (tapsmith-core) runs a local MITM proxy. Traffic routing depends on the platform:
| Platform | Mechanism | Scope |
|---|---|---|
| Android | iptables transparent redirect (rooted emulator images), falling back to the global HTTP proxy setting |
All apps on the emulator/device |
| iOS simulator | macOS Network Extension (per-PID filtering) | Only the simulator’s process tree |
| iOS physical | Wi-Fi proxy via .mobileconfig profile |
System-wide (all apps on the device) |
HTTPS is decrypted using an auto-generated CA certificate installed on the device. On iOS simulators, this is injected transparently via xcrun simctl keychain add-root-cert. On Android, it is installed into the system trust store via adb root, which requires a rootable emulator image — see Android emulator image requirements. Apps or SDKs that use embedded roots, certificate pinning, custom TLS verification, or another transport Tapsmith cannot speak may still reject that CA. Configure networkPassthroughHosts for those hosts; HTTP/2-capable clients that reject the generated certificate may also be tunneled dynamically.
Captured requests appear in the trace viewer’s Network tab alongside screenshots, view hierarchy snapshots, and console output.
In UI mode, the Network panel refreshes about every 500 ms during a test. Each refresh replaces the previous snapshot; open HTTP/2 streams keep their row and update their captured bodies and sizes as data arrives. The final trace includes the last cumulative snapshot. The empty-panel capture hint appears only when network capture is disabled in the trace configuration.
Local Firebase emulators and custom HTTP ports
Section titled “Local Firebase emulators and custom HTTP ports”On a rootable Android emulator, add the local services’ cleartext ports to your trace configuration:
trace: { mode: 'on', network: true, networkHttpPorts: [8080, 9099, 5001, 9199],}Ports 80 and 443 remain included. The extra ports support HTTP/1.1 and HTTP/2
prior knowledge (h2c), which Firestore uses on its emulator port. For these
transparent cleartext HTTP/1.1 and h2c connections, Tapsmith dials Android’s 10.0.2.2 host alias as host loopback while preserving the original
host and port in captured URLs. This translation applies only to Android
emulators; TLS and forward-proxy connections do not translate the alias. Open Firestore listeners appear with live, cumulative body snapshots.
Start capture before opening the connection, or restart the app during the test;
connections that were already open cannot be redirected retroactively.
networkHttpPorts configures Android’s transparent redirect; it requires
iptables support and is for cleartext HTTP ports, not additional TLS ports.
HTTP/2, gRPC, and passthrough connections
Section titled “HTTP/2, gRPC, and passthrough connections”Capture decrypts and records both HTTP/1.1 and HTTP/2 traffic when the client accepts Tapsmith’s MITM certificate. Most mobile HTTP clients (URLSession, OkHttp, fetch/axios) offer both protocols during the TLS handshake; the proxy negotiates HTTP/1.1 with them (server preference) and captures normally.
Clients that require HTTP/2, such as gRPC libraries, can be captured too: the proxy speaks HTTP/2 to the app and to the origin, forwarding DATA frames and trailers (so grpc-status is preserved) while recording each request/response. device.route(), device.waitForRequest(), and device.waitForResponse() work for those requests only when the client trusts the generated Tapsmith certificate.
Some clients do not trust the device or simulator trust store. Firestore is the common example, and it behaves differently per platform:
- Android: Firestore is captured. Its gRPC stack (gRPC-Java) validates TLS against the platform trust store, so once Tapsmith has installed its CA there, Firestore calls are decrypted and recorded like any other HTTP/2 traffic — each RPC appears as an inspectable request/response, and
device.route()can match and mock it. This needs a rootable emulator image so the CA reaches the system store; see Android emulator image requirements. - iOS: Firestore is tunnelled. Its gRPC stack (gRPC-C++/BoringSSL) compiles its own CA roots into the app binary and passes them explicitly, so it rejects Tapsmith’s certificate no matter what the device trust store contains. No configuration changes that.
firestore.googleapis.comis therefore tunnelled by default on iOS, which keeps Firestore-backed screens working out of the box at the cost of not being able to inspect those calls.
Firestore RPC bodies are protobuf rather than JSON. The trace viewer decodes them: a gRPC body is split into its length-prefixed messages and each is shown as protobuf fields, with a Raw toggle for the underlying bytes.
Protobuf carries field numbers on the wire, never names, so the baseline output is numeric (1: "projects/demo/databases/(default)", in the style of protoc --decode_raw). For services Tapsmith knows — currently Firestore’s Listen — a built-in name table lifts that to real field names, resolves enums, unpacks repeated scalars and formats timestamps:
── message 3 of 64 (902 bytes) document_change { document { name: "projects/demo/databases/(default)/documents/users/u1" fields: displayName: { string_value: "E2E Test User" } onboardingCompletedAt: { timestamp_value: 2026-06-10T11:29:35.776Z } onboardingCompleted: { boolean_value: true } } target_ids: [2] }A body with more than a few messages is led by a per-kind summary, because a long stream is mostly bookkeeping — this one carries two document changes among sixty target acks, and the tallies also make listener churn obvious:
── summary of 64 messages target_change ×60 target_change_type: NO_CHANGE ×23, ADD ×13, CURRENT ×12, REMOVE ×12 document_change ×2 "…/documents/users/u1" filter ×2Unknown services fall back to numbers rather than guessing. Repeated fields are marked [] so a single-element list is not mistaken for a scalar, compressed messages are listed but not inflated (the codec lives in grpc-encoding), and a body truncated by the capture cap says how much is missing.
Open streams are visible too. A long-lived Listen appears in the Network tab marked Streaming, with the request and response headers and body bytes captured so far. Complete gRPC messages decode normally; a final incomplete frame is labelled truncated. The HTTP status is the response’s header status, not proof that the RPC finished; grpc-status only appears if the server has sent it.
Each test takes a frozen, cumulative snapshot when its capture ends. A stream spanning several tests appears once in each test’s trace, with its original start time, elapsed duration and all bytes captured since it opened (up to 1 MiB per direction). Requests that began before the test are labelled Started before this test. Their Time column and waterfall show only the period observed during the current test. The Timing detail retains the original start timestamp and shows Stream age (or Total request duration after completion) separately from Observed during this test. Bodies and byte counts remain cumulative, and the detail panel states this explicitly. Later snapshots may contain more messages; earlier traces do not change. When the stream closes, the next capture contains one completed row, with no duplicate streaming row. Bodies remain capped per open stream, and cancelled stream tasks release their capture buffers.
For any other HTTP/2-capable host that rejects the certificate — including Firestore on Android when the CA only reached the user trust store — Tapsmith detects the rejection and tunnels later connections for the same SNI. The rejection is recognised both when the client sends a TLS alert (unknown_ca, bad_certificate) and when it simply tears the connection down without one — gRPC-C++/BoringSSL stacks abort with a connection reset rather than a decodable alert, so this abrupt-close case is treated as a rejection once it repeats for a host. The app continues to work, and the trace shows a single CONNECT row marked passthrough, but the encrypted requests inside are not available to device.route(), device.waitForRequest(), device.waitForResponse(), or the trace viewer.
A few HTTP/2 specifics to be aware of:
- Long-lived channels are opened once per app process. gRPC clients (Firestore included) multiplex every call over a single h2 connection. If the app was already running when your test started, that connection already exists and nothing new crosses the proxy, so
waitForRequest/waitForResponsenever fire androute()handlers never match – capture looks broken while working correctly. Relaunch the app inside the test (device.launchApp(...)) so the channel is opened during the window you are waiting on. - Streaming / long-lived calls (e.g. Firestore
Listen) are forwarded incrementally and appear in the trace even while open, with a Streaming marker and a snapshot of their partial bodies. Assert on the request event, which fires when the stream opens; awaitForResponseon a stream that never ends will time out. - Captured HTTP/2 header names are lowercase (that’s how HTTP/2 sends them on the wire).
- Request/response bodies in the trace are capped (~1 MB), but the full stream is always forwarded to the app.
Hosts listed in networkPassthroughHosts, HTTP/2-capable hosts that dynamically fall back after a MITM certificate rejection, and all HTTPS on an Android emulator that cannot trust Tapsmith’s CA (a Google Play image — see Android emulator image requirements) are tunneled end-to-end without decryption (see below). A tunneled connection appears in the Network tab as a single CONNECT row with a passthrough badge; the requests inside are encrypted end-to-end and invisible to the proxy, so device.route() / waitForRequest / waitForResponse can never match them.
Force passthrough for specific hosts with networkPassthroughHosts – useful when an app uses certificate pinning for a host, which MITM interception would otherwise break:
import { defineConfig } from "tapsmith"
export default defineConfig({ trace: { mode: "on", networkPassthroughHosts: ["pinned-api.myapp.com"], },})Patterns match against the TLS SNI hostname with the same glob syntax as networkHosts.
Because passthrough connections are never decrypted, device.route(), device.waitForRequest(), and device.waitForResponse() can never match traffic on them. If a mock for a host you’ve added to networkPassthroughHosts never fires, or a Firestore/pinned-host request only appears as CONNECT passthrough, this is why.
Viewing network data in traces
Section titled “Viewing network data in traces”Open a trace archive:
npx tapsmith show-trace tapsmith-results/traces/trace-my_test.zipThe Network tab shows a sortable table with columns for method, URL, status code, content type, duration, and response size. Click a row to expand request/response headers and bodies. JSON bodies are pretty-printed automatically, and gRPC/protobuf bodies are decoded into their messages and fields (see HTTP/2, gRPC, and passthrough connections) with a Raw toggle for the original bytes.
Image bodies (PNG, JPEG, GIF, WebP, AVIF, SVG, BMP, ICO) are rendered as pictures with their dimensions (an SVG shows them only when it declares its own size), including compressed or chunked ones (Tapsmith decodes those when it records them). PNG, JPEG, GIF and WebP are also recognised by their bytes when served with a generic type such as application/octet-stream. A Raw toggle shows the bytes. An image isn’t previewed, and the viewer says why, when the capture holds only part of it (bodies are captured up to 1 MiB, a download may still be open, the connection may have stalled before its Content-Length, or a PNG, JPEG, GIF, WebP, BMP or ICO stops before the end its own structure declares — data appended after the end is fine, and so is a GIF without its trailer byte), when it’s a 206 Partial Content range, when a compressed body couldn’t be decompressed, or when its format isn’t one browsers display (such as TIFF).
Route handler actions also appear as events in the trace viewer’s actions panel (e.g., route.fulfill, route.abort), with the source location of your handler code highlighted.
API request fixture in traces
Section titled “API request fixture in traces”When using the request fixture for test-level API calls (seeding data, checking backend state), those calls also appear in the Network tab and the actions panel:
test("shows created item", async ({ device, request }) => { // This POST appears in the trace await request.post("https://api.example.com/items", { data: { name: "Test Item" }, })
await device.getByText("Refresh").tap() await expect(device.getByText("Test Item")).toBeVisible()})Filtering captured traffic
Section titled “Filtering captured traffic”On Android and iOS physical devices, the proxy captures traffic from all apps, not just the app under test. System services, Google Play, Apple background tasks, and other apps all show up. Use networkHosts and networkIgnoreHosts to control what ends up in the trace.
Allowlist – only keep traffic from your app’s API hosts:
import { defineConfig } from "tapsmith"
export default defineConfig({ trace: { mode: "retain-on-failure", networkHosts: ["*.myapp.com", "api.partner.example"], },})Denylist – keep everything except known noise:
import { defineConfig } from "tapsmith"
export default defineConfig({ trace: { mode: "on", networkIgnoreHosts: [ // Android emulator system traffic "connectivitycheck.gstatic.com", "*.googleapis.com", "play.googleapis.com", "mtalk.google.com", "android.clients.google.com", "www.google.com", "clients*.google.com", // iOS background traffic (physical devices) "*.apple.com", "*.icloud.com", "captive.apple.com", ], },})Both fields accept glob patterns. Matching is case-insensitive. When both are set, an entry is kept only if it matches the allowlist AND does not match the denylist – deny wins on conflicts.
iOS simulators are already per-PID filtered by the macOS Network Extension, so system noise is minimal. The filters still work there for additional cleanup if needed.
Platform differences
Section titled “Platform differences”Android emulators/devices: On rootable emulator images, traffic is redirected to the proxy transparently with iptables. Where root is unavailable, the HTTP proxy is instead set globally via adb shell settings put global http_proxy. Either way, every app and system service on the device routes through it – use networkHosts or networkIgnoreHosts to reduce noise in traces. HTTPS decryption additionally requires a rootable (non-Play) emulator image; see Android emulator image requirements.
iOS simulators: The macOS Network Extension intercepts traffic per-PID, filtering to only the simulator’s process tree. Parallel iOS workers each get their own isolated capture session. Host browser traffic is never affected. See iOS Network Capture for first-run setup (a one-time System Extension approval).
iOS physical devices: A Wi-Fi proxy configuration profile (.mobileconfig) routes the device’s traffic through the host Mac. This is system-wide – there is no per-app filtering at the OS level. Use networkHosts to scope what appears in the trace. See iOS Network Capture for the device-specific setup flow.
Android emulator image requirements
Section titled “Android emulator image requirements”HTTPS decryption on Android emulators requires adb root, which only works on Google APIs (google_apis) and AOSP system images. Google Play images (google_apis_playstore) are production builds on which adbd cannot restart as root, so Tapsmith cannot install its CA certificate into the system trust store or set up the iptables redirect. Nothing on the emulator would trust Tapsmith’s certificate, so on a Play image Tapsmith passes HTTPS through to the real servers instead of decrypting it:
- tests still run, and the app’s HTTPS requests still work;
- each HTTPS connection appears in the trace only as a
passthroughentry naming the host, with no request or response detail; device.route(),waitForRequest()andwaitForResponse()cannot see HTTPS requests;- plain-HTTP traffic is still captured, through the global proxy fallback.
Tapsmith prints this at startup:
Network capture warning: Could not install Tapsmith's CA certificate on the emulator:Device does not support adb root — this emulator runs a production system image, whichblocks root. Recreate the AVD with a Google APIs image (`npx tapsmith create-avd`) toenable HTTPS capture. HTTPS is passed through to itsservers uncaptured, so the app's HTTPS requests still work, but they appear in the traceonly as passthrough entries, and device.route(), waitForRequest() and waitForResponse()cannot see them; plain HTTP is still capturedNetwork capture warning: iptables redirect unavailable; using Android system HTTP proxy fallbackAndroid Studio’s Device Manager preselects Google Play images for most phone profiles (marked with a Play Store icon next to the system image). When creating an AVD for Tapsmith, pick an image from the Google APIs tab instead — or let Tapsmith do it:
npx tapsmith create-avdThis downloads the Google APIs system image for your host architecture and creates a ready-to-use AVD (Tapsmith_Phone_API_36 by default; see npx tapsmith create-avd --help for --api, --name, --device, and --abi). If the Android SDK command-line tools are missing — Android Studio doesn’t install them by default — the command offers to install those too. The equivalent manual commands:
sdkmanager "system-images;android-36;google_apis;arm64-v8a" # use x86_64 on Intel hostsavdmanager create avd -n Tapsmith_Phone_API_36 \ -k "system-images;android-36;google_apis;arm64-v8a" -d medium_phonenpx tapsmith doctor flags any existing AVD that uses a Google Play image, and suggests a capture-capable AVD you already have or a new one to create. It never suggests overwriting yours. With network capture on, npx tapsmith init picks a capture-capable AVD when there is one, marks Play images “no HTTPS capture” in the wizard, and warns when it can only pick a Play image. When the AVD it sets up is a Play image, the wizard’s network-capture question defaults to no (unless iOS is set up too), as does init --yes without --network-capture, and its next steps name the capture-capable AVD to switch to, or the create-avd command for one (and, if you declined capture, the trace setting that turns it on).
To check an existing AVD, look at tag.id in ~/.android/avd/<name>.avd/config.ini: google_apis (or default/aosp_atd) supports HTTPS capture; google_apis_playstore does not.
Manually installing ~/.tapsmith/ca.pem as a user CA does not help on a Play-image emulator: Tapsmith cannot see a user-installed CA, so it passes HTTPS through uncaptured there either way. Use a Google APIs image instead. (On a physical Android device, where there is no adb root either, Tapsmith keeps decrypting HTTPS, so a user-installed CA that your test build opts into via network_security_config.xml does work.)
Flutter apps
Section titled “Flutter apps”On an iOS simulator and a rootable Android emulator (a Google APIs or AOSP image), Tapsmith captures a Flutter app’s traffic from package:http, dio, cupertino_http and cronet_http: the trace records it with decrypted bodies, and device.route(), waitForRequest() and waitForResponse() see it. Dart trusts Tapsmith’s CA there without any change to the app.
Elsewhere, Tapsmith captures through a proxy setting: on a Google Play emulator image, a physical iOS device, and the iOS simulator’s macOS system-proxy fallback, which GitHub-hosted runners use. Dart’s own HTTP client (dart:io’s HttpClient, which package:http and dio use by default) ignores the device’s proxy setting, so there their requests are not captured and routes and waiters do not see them. The app keeps working. A client built on the platform’s networking stack, cupertino_http on iOS or cronet_http on Android, does go through the proxy and is captured.
Plain-HTTP requests from a Flutter app on Android need android:usesCleartextTraffic="true" in the app’s manifest (or a network security config that allows the host), as for any Android app.
When the app rejects Tapsmith’s certificate
Section titled “When the app rejects Tapsmith’s certificate”Tapsmith decrypts HTTPS with its own CA, ~/.tapsmith/ca.pem, created the first time a session selects an Android device or captures traffic. On rootable Android emulators it switches adbd to root and installs that CA into the device’s system trust store when a test session selects the device, before the agent starts and the app launches. It does this whether or not the trace records network, since any test can start the proxy on demand. If the emulator already trusts a different Tapsmith CA, for example one from an earlier ~/.tapsmith that is still in a quick-boot snapshot or a shared AVD, Tapsmith replaces it.
When an HTTPS client on the device still refuses Tapsmith’s certificate, those requests do not appear in the trace and device.route() cannot intercept them. Tapsmith then names the hosts in the run output, once per run, and adds the same warning to each affected test’s trace as a daemon console message. Hosts excluded by networkHosts or networkIgnoreHosts are left out, and there is no warning on the iOS simulator’s macOS system-proxy fallback, where other Mac processes’ traffic reaches the proxy too:
[tapsmith] Network capture warning: an HTTPS client on the device rejected Tapsmith's certificate for api.example.com, so those requests were not captured and device.route() could not intercept them. …If every host your app uses is rejected, the device or app does not trust the current CA. For example, the CA could only be installed in Android’s user store, which apps ignore unless their network_security_config.xml opts in. If only some hosts are rejected, those clients probably pin their certificates. List your app’s pinned hosts in trace.networkPassthroughHosts so they are tunnelled without capture instead of failing. On system-wide capture routes the hosts can also belong to background services, which you can drop with trace.networkIgnoreHosts.
OS services that pin or distrust the CA are left out of the warning: with Android transparent capture (a rootable emulator), system UIDs and preinstalled system apps such as Google Play services, the Play Store and Chrome; on the iOS simulator, system daemons such as trustd and iCloud’s. Their rejects say nothing about your app, so they only get a one-line info note in the trace. Background URLSessions and WKWebView traffic on the iOS simulator count as the app’s. Routes that cannot tell which process opened a connection (the Android system HTTP proxy fallback, a physical iOS device) still name every rejected host.
Reference
Section titled “Reference”- API Reference – full method signatures for
device.route(),Route,TapsmithRequest, and related types - Trace Viewer – how to view and navigate trace archives
- Configuration –
TraceConfigoptions includingnetwork,networkHosts,networkIgnoreHosts,networkPassthroughHosts - iOS Network Capture – iOS-specific setup, troubleshooting, and the Network Extension architecture

