From dbe54c99f36e0f1b424b35fe965ec230a76393a2 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 22 Oct 2026 11:41:09 +0530 Subject: [PATCH 01/11] feat(core): read native app network from the appium-interceptor proxy A native app has no document and Appium serves no BiDi network events, so a native session's requests reached no Network tab. The appium-interceptor plugin routes the device through a proxy that records them; InterceptorNetworkPoller starts its listener and turns each new entry into a NetworkRequest. It talks to the plugin over raw WebDriver HTTP so its calls never re-enter an adapter's command hooks, and chains drains so overlapping reads cannot list an entry twice. --- packages/core/src/appium-interceptor.ts | 127 ++++++++++++++ packages/core/src/index.ts | 1 + .../core/tests/appium-interceptor.test.ts | 166 ++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 packages/core/src/appium-interceptor.ts create mode 100644 packages/core/tests/appium-interceptor.test.ts diff --git a/packages/core/src/appium-interceptor.ts b/packages/core/src/appium-interceptor.ts new file mode 100644 index 00000000..76170a4d --- /dev/null +++ b/packages/core/src/appium-interceptor.ts @@ -0,0 +1,127 @@ +// Native app network, read from the appium-interceptor plugin's proxy: a native app has no document and Appium serves no BiDi network events. + +import { getRequestType, type NetworkRequest } from '@wdio/devtools-shared' +import { webdriverExecute, type WebDriverAddress } from './webdriver-http.js' + +/** One entry of the plugin's `interceptor: getInterceptedData`. */ +export interface InterceptedRequest { + url: string + method: string + statusCode: number + requestHeaders?: Record + requestBody?: unknown + responseHeaders?: Record + responseBody?: unknown +} + +export function flattenInterceptedHeaders( + headers: InterceptedRequest['requestHeaders'] +): Record { + const flat: Record = {} + for (const [name, value] of Object.entries(headers ?? {})) { + // The plugin files the response status line as a header ("http/1.1 201 created"). + if (/\s/.test(name)) { + continue + } + flat[name] = Array.isArray(value) ? value.join(', ') : value + } + return flat +} + +export function interceptedBodyText(body: unknown): string | undefined { + if (body === undefined || body === null || body === '') { + return undefined + } + return typeof body === 'string' ? body : JSON.stringify(body) +} + +/** The plugin records no timing, so a row is stamped when it was first read. */ +export function interceptedToNetworkRequest( + entry: InterceptedRequest, + id: string, + observedAt: { timestamp: number; startTime: number } +): NetworkRequest { + const responseHeaders = flattenInterceptedHeaders(entry.responseHeaders) + const responseBody = interceptedBodyText(entry.responseBody) + return { + id, + url: entry.url, + method: entry.method, + status: entry.statusCode, + type: getRequestType(entry.url, responseHeaders['content-type']), + timestamp: observedAt.timestamp, + startTime: observedAt.startTime, + endTime: observedAt.startTime, + requestHeaders: flattenInterceptedHeaders(entry.requestHeaders), + responseHeaders, + response: { + fromCache: false, + headers: responseHeaders, + mimeType: responseHeaders['content-type']?.split(';')[0]?.trim() ?? '', + status: entry.statusCode + }, + requestBody: interceptedBodyText(entry.requestBody), + responseBody, + size: + responseBody === undefined ? undefined : Buffer.byteLength(responseBody) + } +} + +/** Talks to the plugin over raw WebDriver HTTP, so its commands never re-enter the client's hooks or render as action rows. */ +export class InterceptorNetworkPoller { + #address: WebDriverAddress + #sessionId: string + #listenerId?: string + #seen = 0 + // Drains are chained: two overlapping reads would both list the entries past the same `#seen`. + #tail: Promise = Promise.resolve([]) + + constructor(address: WebDriverAddress, sessionId: string) { + this.#address = address + this.#sessionId = sessionId + } + + async start(): Promise { + const id = await webdriverExecute( + this.#address, + this.#sessionId, + 'interceptor: startListening', + [{}] + ) + this.#listenerId = typeof id === 'string' ? id : undefined + return this.#listenerId !== undefined + } + + drain(): Promise { + this.#tail = this.#tail.then(() => this.#readFresh()) + return this.#tail + } + + async #readFresh(): Promise { + const listenerId = this.#listenerId + if (!listenerId) { + return [] + } + const data = await webdriverExecute( + this.#address, + this.#sessionId, + 'interceptor: getInterceptedData', + [{ id: listenerId }] + ) + if (!Array.isArray(data) || data.length <= this.#seen) { + return [] + } + const observedAt = { timestamp: Date.now(), startTime: performance.now() } + const fresh = data + .slice(this.#seen) + .map((entry, offset) => + interceptedToNetworkRequest( + entry, + `interceptor-${listenerId}-${this.#seen + offset}`, + observedAt + ) + ) + this.#seen = data.length + return fresh + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7b340587..540a05cc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,7 @@ export * from './screenshot-artifact.js' export * from './video-slice.js' export * from './with-timeout.js' export * from './webdriver-http.js' +export * from './appium-interceptor.js' export * from './viewport.js' export * from './assert-patcher.js' export * from './element-snapshot.js' diff --git a/packages/core/tests/appium-interceptor.test.ts b/packages/core/tests/appium-interceptor.test.ts new file mode 100644 index 00000000..841c4832 --- /dev/null +++ b/packages/core/tests/appium-interceptor.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../src/webdriver-http.js', () => ({ webdriverExecute: vi.fn() })) + +import { webdriverExecute } from '../src/webdriver-http.js' +import { + InterceptorNetworkPoller, + flattenInterceptedHeaders, + interceptedBodyText, + interceptedToNetworkRequest, + type InterceptedRequest +} from '../src/appium-interceptor.js' + +const execute = vi.mocked(webdriverExecute) +const ADDRESS = { hostname: '127.0.0.1', port: 4723 } + +const entry = (over: Partial = {}): InterceptedRequest => ({ + url: 'https://api.example.com/items', + method: 'GET', + statusCode: 200, + responseHeaders: { 'content-type': 'application/json; charset=utf-8' }, + responseBody: { ok: true }, + ...over +}) + +describe('flattenInterceptedHeaders', () => { + it('joins repeated headers and drops the status line the plugin files as a header', () => { + expect( + flattenInterceptedHeaders({ + 'http/1.1 201 created': '', + 'set-cookie': ['a=1', 'b=2'], + 'content-type': 'text/plain' + }) + ).toEqual({ 'set-cookie': 'a=1, b=2', 'content-type': 'text/plain' }) + }) + + it('returns an empty map for absent headers', () => { + expect(flattenInterceptedHeaders(undefined)).toEqual({}) + }) +}) + +describe('interceptedBodyText', () => { + it('keeps a string, serializes a parsed body, and reads empty as absent', () => { + expect(interceptedBodyText('raw')).toBe('raw') + expect(interceptedBodyText({ a: 1 })).toBe('{"a":1}') + expect(interceptedBodyText('')).toBeUndefined() + expect(interceptedBodyText(null)).toBeUndefined() + expect(interceptedBodyText(undefined)).toBeUndefined() + }) +}) + +describe('interceptedToNetworkRequest', () => { + it('maps an entry onto a network row stamped when it was observed', () => { + const row = interceptedToNetworkRequest( + entry({ requestHeaders: { accept: '*/*' }, requestBody: 'q=1' }), + 'interceptor-1-0', + { timestamp: 1000, startTime: 50 } + ) + expect(row).toMatchObject({ + id: 'interceptor-1-0', + url: 'https://api.example.com/items', + method: 'GET', + status: 200, + type: 'fetch', + timestamp: 1000, + startTime: 50, + endTime: 50, + requestHeaders: { accept: '*/*' }, + requestBody: 'q=1', + responseBody: '{"ok":true}', + size: 11, + response: { + fromCache: false, + mimeType: 'application/json', + status: 200 + } + }) + }) + + it('measures size in bytes, not characters', () => { + const row = interceptedToNetworkRequest( + entry({ responseBody: 'é' }), + 'id', + { timestamp: 0, startTime: 0 } + ) + expect(row.size).toBe(2) + }) + + it('leaves mime type and size empty when the response carries neither', () => { + const row = interceptedToNetworkRequest( + entry({ responseHeaders: undefined, responseBody: undefined }), + 'id', + { timestamp: 0, startTime: 0 } + ) + expect(row.response?.mimeType).toBe('') + expect(row.size).toBeUndefined() + }) +}) + +describe('InterceptorNetworkPoller', () => { + beforeEach(() => { + execute.mockReset() + }) + + it('starts a listener and reads only the entries it has not listed yet', async () => { + const poller = new InterceptorNetworkPoller(ADDRESS, 'sess') + execute.mockResolvedValueOnce('L1') + expect(await poller.start()).toBe(true) + expect(execute).toHaveBeenLastCalledWith( + ADDRESS, + 'sess', + 'interceptor: startListening', + [{}] + ) + + execute.mockResolvedValueOnce([entry()]) + const first = await poller.drain() + expect(first.map((r) => r.id)).toEqual(['interceptor-L1-0']) + expect(execute).toHaveBeenLastCalledWith( + ADDRESS, + 'sess', + 'interceptor: getInterceptedData', + [{ id: 'L1' }] + ) + + execute.mockResolvedValueOnce([entry(), entry({ method: 'POST' })]) + const second = await poller.drain() + expect(second.map((r) => [r.id, r.method])).toEqual([ + ['interceptor-L1-1', 'POST'] + ]) + + execute.mockResolvedValueOnce([entry(), entry({ method: 'POST' })]) + expect(await poller.drain()).toEqual([]) + }) + + it('does not list an entry twice when drains overlap', async () => { + const poller = new InterceptorNetworkPoller(ADDRESS, 'sess') + execute.mockResolvedValueOnce('L1') + await poller.start() + execute.mockResolvedValue([entry()]) + const [a, b] = await Promise.all([poller.drain(), poller.drain()]) + expect([...a, ...b]).toHaveLength(1) + }) + + it('reports a failed start and then reads nothing', async () => { + const poller = new InterceptorNetworkPoller(ADDRESS, 'sess') + execute.mockResolvedValueOnce(null) + expect(await poller.start()).toBe(false) + expect(await poller.drain()).toEqual([]) + expect(execute).toHaveBeenCalledTimes(1) + }) + + it('reads nothing from a failed or malformed read and keeps its position', async () => { + const poller = new InterceptorNetworkPoller(ADDRESS, 'sess') + execute.mockResolvedValueOnce('L1') + await poller.start() + execute.mockResolvedValueOnce(null) + expect(await poller.drain()).toEqual([]) + execute.mockResolvedValueOnce({ error: 'x' }) + expect(await poller.drain()).toEqual([]) + execute.mockResolvedValueOnce([entry()]) + expect((await poller.drain()).map((r) => r.id)).toEqual([ + 'interceptor-L1-0' + ]) + }) +}) From 38f6a2b731ad1628ad5463ba0527bfc664aba6b4 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 22 Oct 2026 11:41:09 +0530 Subject: [PATCH 02/11] feat(service): list native app requests when the session sets appium:intercept Starts the interceptor listener for a native session that asked for the proxy, forwards what it recorded after each top-level command and at session end, and re-arms it on reloadSession because a listener dies with its session. Any other session, or Appium without the plugin, runs as before; the latter logs a warning. --- packages/service/src/index.ts | 9 ++ packages/service/src/intercepted-network.ts | 56 ++++++++ .../service/tests/intercepted-network.test.ts | 121 ++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 packages/service/src/intercepted-network.ts create mode 100644 packages/service/tests/intercepted-network.test.ts diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 07ee2321..a085fac4 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -62,6 +62,7 @@ import { inPageProbesDeadlock, isAppiumSession } from './mobile.js' import { resolveSessionMetadata } from './session-metadata.js' import { stampRunnerMetadata } from './wdio-runner-id.js' import { publishConfigFile } from './standalone.js' +import { InterceptedNetwork } from './intercepted-network.js' export * from './types.js' export const launcher = DevToolsAppLauncher @@ -89,6 +90,7 @@ export default class DevToolsHookService implements Services.ServiceInstance { * millisecond, so a scan mistook it for this slot and skipped the only * capture of the last action's result. */ #finalizedSlot?: { command: string; timestamp: number } + #nativeNetwork?: InterceptedNetwork #assertionTracker: AssertionTracker #screencast: ScreencastLifecycle #slices: TraceSliceTracker @@ -281,6 +283,8 @@ export default class DevToolsHookService implements Services.ServiceInstance { } await this.#screencast.start(browser) + this.#nativeNetwork = new InterceptedNetwork(caps, this.#sessionCapturer) + await this.#nativeNetwork.start(browser) /** * Propagate session metadata at the beginning of the session. Not awaited @@ -806,6 +810,7 @@ export default class DevToolsHookService implements Services.ServiceInstance { if (this.#options.mode !== 'trace') { await this.#drainAfterLiveCommand(command) } + await this.#nativeNetwork?.forward() return captured } } @@ -853,6 +858,7 @@ export default class DevToolsHookService implements Services.ServiceInstance { // finalize on — would lose it. A framework run has already finalized per // test; there this only re-captures the same timestamp and merges. await this.#finalizePerScenario() + await this.#nativeNetwork?.forward() // Stop and encode the screencast for the current session. await this.#screencast.finalize(this.#browser.sessionId) @@ -876,6 +882,9 @@ export default class DevToolsHookService implements Services.ServiceInstance { // independent of screencast, so it runs before that early-returns. this.#sessionCapturer.resetScriptInjection() await this.#ensureInjected('reloadSession') + if (this.#browser) { + await this.#nativeNetwork?.start(this.#browser) + } await this.#screencast.handleReload(oldSessionId) } diff --git a/packages/service/src/intercepted-network.ts b/packages/service/src/intercepted-network.ts new file mode 100644 index 00000000..249b79d3 --- /dev/null +++ b/packages/service/src/intercepted-network.ts @@ -0,0 +1,56 @@ +import logger from '@wdio/logger' +import { InterceptorNetworkPoller } from '@wdio/devtools-core' +import { isNativeAppSession } from '@wdio/devtools-shared' +import type { SessionCapturer } from './session.js' +import { resolveWebDriverAddress } from './webdriver-address.js' + +const log = logger('@wdio/devtools-service:interceptedNetwork') + +/** Native app network from appium-interceptor's proxy, for a session that asked for it with `appium:intercept`. */ +export class InterceptedNetwork { + #requested: boolean + #capturer: SessionCapturer + #poller?: Pick + + constructor(requestedCaps: unknown, capturer: SessionCapturer) { + this.#requested = + (requestedCaps as Record | undefined)?.[ + 'appium:intercept' + ] === true + this.#capturer = capturer + } + + /** Called per session: a listener dies with its session, so `reloadSession` needs a new one. */ + async start(browser: WebdriverIO.Browser): Promise { + this.#poller = undefined + if (!this.#requested || !isNativeAppSession(browser.capabilities)) { + return + } + const address = resolveWebDriverAddress(browser) + if (!address || !browser.sessionId) { + log.warn( + 'appium:intercept is set but the Appium address is not knowable; native network is not captured' + ) + return + } + const poller = new InterceptorNetworkPoller(address, browser.sessionId) + if (!(await poller.start())) { + log.warn( + 'appium:intercept is set but the interceptor listener did not start; is Appium running with --use-plugins=appium-interceptor?' + ) + return + } + log.info('Native network capture active via appium-interceptor') + this.#poller = poller + } + + /** Lists what the proxy recorded since the last call. */ + async forward(): Promise { + const requests = (await this.#poller?.drain()) ?? [] + if (requests.length === 0) { + return + } + this.#capturer.networkRequests.push(...requests) + this.#capturer.sendUpstream('networkRequests', requests) + } +} diff --git a/packages/service/tests/intercepted-network.test.ts b/packages/service/tests/intercepted-network.test.ts new file mode 100644 index 00000000..aabd4f97 --- /dev/null +++ b/packages/service/tests/intercepted-network.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLogger, poller } = vi.hoisted(() => ({ + mockLogger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, + poller: { start: vi.fn(), drain: vi.fn(), args: [] as unknown[] } +})) + +vi.mock('@wdio/logger', () => ({ default: vi.fn(() => mockLogger) })) + +vi.mock('@wdio/devtools-core', async (importOriginal) => ({ + ...(await importOriginal()), + InterceptorNetworkPoller: class { + constructor(...args: unknown[]) { + poller.args = args + } + start = poller.start + drain = poller.drain + } +})) + +import { InterceptedNetwork } from '../src/intercepted-network.js' +import type { SessionCapturer } from '../src/session.js' + +const NATIVE_CAPS = { + platformName: 'Android', + 'appium:automationName': 'UiAutomator2' +} +const REQUESTED = { ...NATIVE_CAPS, 'appium:intercept': true } + +const browser = (over: Record = {}) => + ({ + sessionId: 'sess', + capabilities: NATIVE_CAPS, + options: { hostname: '127.0.0.1', port: 4723, path: '/' }, + ...over + }) as unknown as WebdriverIO.Browser + +const capturer = () => + ({ + networkRequests: [], + sendUpstream: vi.fn() + }) as unknown as SessionCapturer + +describe('InterceptedNetwork', () => { + beforeEach(() => { + vi.clearAllMocks() + poller.args = [] + }) + + it('starts a listener for a native session that asked for the proxy', async () => { + poller.start.mockResolvedValueOnce(true) + await new InterceptedNetwork(REQUESTED, capturer()).start(browser()) + expect(poller.start).toHaveBeenCalledTimes(1) + expect(poller.args[0]).toMatchObject({ hostname: '127.0.0.1', port: 4723 }) + expect(poller.args[1]).toBe('sess') + }) + + it('stays off when the session did not ask for the proxy', async () => { + await new InterceptedNetwork(NATIVE_CAPS, capturer()).start(browser()) + await new InterceptedNetwork( + { ...NATIVE_CAPS, 'appium:intercept': 'yes' }, + capturer() + ).start(browser()) + expect(poller.start).not.toHaveBeenCalled() + }) + + it('stays off for a session with a document, whose network the page already captures', async () => { + const web = browser({ + capabilities: { platformName: 'Android', browserName: 'chrome' } + }) + await new InterceptedNetwork(REQUESTED, capturer()).start(web) + expect(poller.start).not.toHaveBeenCalled() + }) + + it('warns and stays off when the driver address is not knowable', async () => { + await new InterceptedNetwork(REQUESTED, capturer()).start( + browser({ options: {} }) + ) + expect(poller.start).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('address is not knowable') + ) + }) + + it('warns and forwards nothing when the plugin does not answer', async () => { + poller.start.mockResolvedValueOnce(false) + const target = capturer() + const network = new InterceptedNetwork(REQUESTED, target) + await network.start(browser()) + await network.forward() + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('appium-interceptor') + ) + expect(poller.drain).not.toHaveBeenCalled() + expect(target.sendUpstream).not.toHaveBeenCalled() + }) + + it('records and streams the rows a drain returns, and nothing for an empty one', async () => { + poller.start.mockResolvedValueOnce(true) + const rows = [{ id: 'interceptor-L1-0' }] + poller.drain.mockResolvedValueOnce(rows).mockResolvedValueOnce([]) + const target = capturer() + const network = new InterceptedNetwork(REQUESTED, target) + await network.start(browser()) + await network.forward() + await network.forward() + expect(target.networkRequests).toEqual(rows) + expect(target.sendUpstream).toHaveBeenCalledTimes(1) + expect(target.sendUpstream).toHaveBeenCalledWith('networkRequests', rows) + }) + + it('starts a fresh listener for a reloaded session and drops the old one', async () => { + poller.start.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + const network = new InterceptedNetwork(REQUESTED, capturer()) + await network.start(browser()) + await network.start(browser({ sessionId: 'sess-2' })) + expect(poller.args[1]).toBe('sess-2') + await network.forward() + expect(poller.drain).not.toHaveBeenCalled() + }) +}) From 69da8794044f5654c91165efeaa224663ef15823 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Thu, 22 Oct 2026 11:41:09 +0530 Subject: [PATCH 03/11] docs: describe native network capture through appium-interceptor The service README says how to enable it and what a row carries, MOBILE.md lists the remaining adapter gap, and CLAUDE.md's network entry no longer says a native session has no network capture at all. --- .changeset/native-network-interceptor.md | 5 +++++ CLAUDE.md | 2 +- examples/MOBILE.md | 7 ++++++- packages/service/README.md | 24 ++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 .changeset/native-network-interceptor.md diff --git a/.changeset/native-network-interceptor.md b/.changeset/native-network-interceptor.md new file mode 100644 index 00000000..603d285c --- /dev/null +++ b/.changeset/native-network-interceptor.md @@ -0,0 +1,5 @@ +--- +'@wdio/devtools-service': minor +--- + +Capture a native app's network in the Network tab through the appium-interceptor plugin. A native Appium session has no document and no BiDi network events, so its requests were never visible. When the session sets `appium:intercept: true` and Appium runs with `--use-plugins=appium-interceptor`, the service now lists each request the plugin's proxy recorded, with method, URL, status, headers and bodies, in live and trace mode. Without the plugin the session runs as before and a warning is logged. diff --git a/CLAUDE.md b/CLAUDE.md index e06da11a..a769c28f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,7 +274,7 @@ Documented divergences from the conventions above. They exist today as debt to b - **Network has three sources, and the page's Resource Timing is the floor.** BiDi (all four adapters) and Nightwatch's Chrome performance log are driver-side and carry headers and status. The page collector carries two: its patched `fetch`/`XMLHttpRequest` (the only source with bodies) and the document's Resource Timing buffer (`script/src/collectors/resourceTiming.ts`): every load with timing phases and size, a status on Chromium only, never headers. Resource Timing is the only network source a browser without BiDi has, which is Safari and every Appium session (Appium's BiDi reference lists `session.*` alone). The collector reads the buffer at construction, which is what covers loads made before a `