Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/adr/0038-network-dump-source-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 0038: Network dump source selection

## Status

Accepted for this PR's sources (provider, app-log) and the device-identity gate. ADR 0037 (the iOS Simulator network capture probe) is proposed in #3928 and names `probe` and `app-log`; the `probe` row below and the cross-PR ordering are this record's proposal to it. Before both land, one record must own the enum: the PR that lands second amends its own record to the single enum and owner named here, or records a different order it can defend.

## Rules at a glance

- `network dump` answers from exactly one source, reported in its `source` field: `probe` (injected iOS native probe, 0037), `provider` (injected React Native JS capture, this record), or `app-log` (session app log, all sessions).
- The platform runtime owns the ordering. Each platform runtime lists its sources once per dump, takes the first that answers with entries, and builds the app-log scan with its own platform recovery around the rest. A source never reaches into another source's state.
- Injection policy: no capture installs in the app under test unless an explicit operator command arms it, and the command that arms says so. `open --capture-network` arms the iOS probe at launch (0037); the first `network dump` of a React Native app session with a Metro binding arms the JS capture and reports the arming note. Nothing arms from a background path.
- The React Native provider injects into one runtime only: the dev-server runtime of the session's app on the device named by the platform-composed Metro device identity. The identity is granted only when the platform proves the name marks one attachable device. Android lists every serial adb reports, in any state including offline and unauthorized, and reads each serial's properties; any unreadable read withholds the identity. Apple lists booted simulators in every simulator set the host can name — the device's own set, the default set, and the daemon operator's set — and the connected physical devices devicectl reports; an unreadable listing, an unreadable physical-device answer, or a set the host cannot reconcile with its own configuration withholds the identity. A withheld identity fails the provider closed with `metro-device-mismatch`; the dump still answers from the app log. The identity probe runs lazily inside a dump, so only a session whose transport declared a provider dump pays for the probe.

## Decision

The React Native JS capture is a `provider` source, preferred over the app log and preferred under the probe. When both injected sources are armed for one session (an iOS simulator app opened with `--capture-network` that also loads its bundle from Metro), the probe answers first: it names every URLSession request, including the JS capture's own and any native-transport traffic. The JS capture adds headers and bodies observed at the JS layer, so it answers when the probe is not armed. The app log answers last and always remains available.

Selection stays inside the platform runtime rather than a shared walker because the app-log fallbacks are not shareable: Apple recovers from `simctl log show`, Android from `adb logcat` with PID handling. A shared walker would need host slots per platform and would re-create that split behind an indirection.

## Reason

The provider answers with exact method, URL, status, duration, headers, and bodies, which the app log rarely names. The fail-closed device-identity rule exists because a runtime is selected by a name the dev server reports: two emulators of one system image, or two same-name simulators, report one name, and the weaker match let one session hook, drain, and report another device's traffic. The platform that owns the device inventory (adb device properties; `simctl list devices booted`) is the only layer that can say whether a name marks exactly one attached device.

Lazy arming of the JS capture keeps injection honest: code enters the app under test only because a `network dump` asked for it, the first window reads the app log with an explanatory note, and the same rule generalizes to the probe, which arms only under an explicit open flag.

## Rejected alternatives

- Arming the JS capture at `open` when Metro is bound. It injects before any command asks for network data and needs an attach-retry policy for cold-start races with the dev server.
- Selecting runtimes by a shared display name, model, or alias list. A name two attached devices share is not an identity; the review of #3930 showed one session draining another device's buffer that way. The platform now passes a composed identity only when it checked every attached device for a collision.
- Matching the dev server's `<model> - <release> - API <level>` report by model prefix. Same defect: same-image emulators collide on the prefix.
- Subscribing to the RN DevTools built-in Network CDP domain instead of injecting code. It needs a daemon-held CDP connection for the session's lifetime and drops React Native versions without the domain; the injected runtime is the version-agnostic core.

## Consequences

- A source field value is added only with its owner and its place in the order recorded here.
- A physical Apple device gets no Metro device identity (its dev-server-reported name has no host-side listing that proves which runtime it belongs to), so the React Native provider stays off there and the dump answers from the app log. Connected physical devices do count against a simulator's identity: a same-named one could attach to the same dev server.
- Accepted risk (Apple sets): `simctl` cannot enumerate simulator sets, so the identity check covers the sets the host can name — the device's own set, the default set, and the set `AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET` configures. A simulator booted in some other set whose path this host was never told about is invisible to every available tool and cannot be ruled out. Operators who share one dev server across simulator sets must keep simulator display names unique across those sets.
- The device-identity gate is a typed note on an `app-log` answer, never a command failure, so a selection problem degrades to the old capture instead of losing the dump.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
| [0034 Provider plugin registry](0034-provider-plugin-registry.md) | provider plugins, the `agent-device/plugins` contract, plugin sources and order, duplicate provider ids, plugin failure isolation, the daemon plugin inventory and `daemon status` |
| [0035 macOS App Devices](0035-macos-app-devices.md) | native macOS apps as devices of their own, the host/app-device overlap rule, concurrent app sessions, and `macos-app` lease claims |
| [0036 Bounded Composite Resource Admission](0036-bounded-composite-resource-admission.md) | resource operations, nested tests, session disposal, and claim recovery inside bounded batch/replay steps |
| [0038 Network Dump Source Selection](0038-network-dump-source-selection.md) | the `network dump` source owner and enum, the lazy-arming rule, and the Metro device-identity gate |

ADRs record rationale.
The registries and gates they describe are the current source of truth.
Expand Down
16 changes: 16 additions & 0 deletions examples/test-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ It is intentionally small, but each surface is dense with durable accessibility
- `Automation lab`: long-press, alert-result, app-event, app-state, appearance, orientation, permission-recovery, log canaries, a flattened (`accessible={true}`) text input, and an Apple Pay sheet hosted in `com.apple.PassbookUIService`
- `WebView accessibility`: a deterministic semantic fixture plus live websites with varied HTML for native accessibility snapshot verification
- `Perf lab`: seeded JavaScript render problems for `react-devtools profile`, one per scenario (see [Perf lab](#perf-lab))
- `Network lab`: deterministic `fetch` and `XMLHttpRequest` traffic for `network dump` verification (see [Network lab](#network-lab))
- `Heavy feed`: a Bluesky-shaped home feed whose accessibility tree is deeper than 65 levels (see [Heavy feed](#heavy-feed))

Navigation uses Expo Router native bottom tabs, so the tab bar itself is also part of the test surface.
Expand Down Expand Up @@ -53,6 +54,7 @@ These are the main case families this app can support without adding more screen
- retryable error banners
- cart counters and quantity changes
- screenshot and recording proof capture
- in-app `network dump` on real `fetch` and `XMLHttpRequest` traffic from the network lab

## Run locally

Expand Down Expand Up @@ -262,6 +264,20 @@ agent-device react-devtools profile stop

`src/screens/PerfLabScreen.tsx` imports only React and React Native, so it can be copied into a bare React Native app to check the profiler on other React Native versions.

## Network lab

`agent-device-test-app://network-lab` fires one real request per press: **Run fetch** (`network-fetch`)
GETs `httpbin.org/json`, **Run post** (`network-post`) POSTs a JSON body to `httpbin.org/post`, and
**Run XHR** (`network-xhr`) GETs through `XMLHttpRequest`. `network-lab-status` reports
`fetch:200:<bytes>` once a request settles. Use it to verify an in-app `network dump`:

```bash
agent-device open "agent-device-test-app://network-lab" --platform ios
agent-device network dump # first dump arms the in-app capture
agent-device click 'id="network-fetch"'
agent-device network dump --include all # reports the GET with status, headers, bodies
```

## Heavy feed

**Settings → Open heavy feed** (`open-heavy-feed`) opens a JavaScript-only reproduction of a heavy
Expand Down
1 change: 1 addition & 0 deletions examples/test-app/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function RootLayoutContent() {
<Stack.Screen name="heavy-feed" options={{ headerShown: true, title: 'Heavy feed' }} />
<Stack.Screen name="ticker" />
<Stack.Screen name="perf-lab" />
<Stack.Screen name="network-lab" options={{ headerShown: true, title: 'Network lab' }} />
<Stack.Screen name="product/[productId]" />
<Stack.Screen name="webview" />
</Stack>
Expand Down
105 changes: 105 additions & 0 deletions examples/test-app/app/network-lab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

const ENDPOINT = 'https://httpbin.org/json';

/**
* Network lab: deterministic in-app traffic for `network dump` verification.
* Each press performs one plain `fetch` or `XMLHttpRequest` so a dump can be
* checked against a request the harness just made.
*/
export default function NetworkLabRoute() {
const insets = useSafeAreaInsets();
const [status, setStatus] = useState('idle');

const runFetch = async () => {
setStatus('running');
try {
const response = await fetch(ENDPOINT, {
method: 'GET',
headers: { 'x-agent-device-lab': 'network', 'x-api-key': 'lab-secret-1' },
});
const body = await response.text();
setStatus(`fetch:${response.status}:${body.length}`);
} catch (error) {
setStatus(`fetch:error:${(error as Error).message}`);
}
};

/**
* One failed fetch followed by a direct XHR to the same URL. The capture must report both:
* the failed fetch's claim on the URL must not hide the XHR that comes after it.
*/
const runFailedFetchThenXhr = async () => {
setStatus('running');
const url = 'https://invalid.agent-device.test/after-failed-fetch';
try {
await fetch(url);
} catch {
// The failure is the fixture; the XHR below is what the dump must still name.
}
const request = new XMLHttpRequest();
request.open('GET', url);
request.onload = () => setStatus(`xhr-after-failed-fetch:${request.status}`);
request.onerror = () => setStatus(`xhr-after-failed-fetch:failed:${request.status}`);
request.send();
};

const runPost = async () => {
setStatus('running');
try {
const response = await fetch('https://httpbin.org/post', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lab: 'network', seq: 1 }),
});
setStatus(`post:${response.status}`);
} catch (error) {
setStatus(`post:error:${(error as Error).message}`);
}
};

const runXhr = () => {
setStatus('running');
const request = new XMLHttpRequest();
request.open('GET', 'https://httpbin.org/get?xhr=1');
request.onload = () => setStatus(`xhr:${request.status}`);
request.onerror = () => setStatus('xhr:error');
request.send();
};

return (
<ScrollView
contentContainerStyle={[styles.content, { paddingTop: insets.top + 16 }]}
style={{ flex: 1 }}
testID="network-lab"
>
<Text testID="network-lab-title">Network lab</Text>
<Pressable onPress={() => void runFetch()} style={styles.button} testID="network-fetch">
<Text>Run fetch</Text>
</Pressable>
<Pressable onPress={() => void runPost()} style={styles.button} testID="network-post">
<Text>Run post</Text>
</Pressable>
<Pressable onPress={runXhr} style={styles.button} testID="network-xhr">
<Text>Run XHR</Text>
</Pressable>
<Pressable
onPress={() => void runFailedFetchThenXhr()}
style={styles.button}
testID="network-failed-fetch-xhr"
>
<Text>Run failed fetch then XHR</Text>
</Pressable>
<View>
<Text testID="network-lab-status">{status}</Text>
</View>
</ScrollView>
);
}

const styles = StyleSheet.create({
content: { padding: 16, gap: 12 },
button: { paddingVertical: 12 },
});
4 changes: 4 additions & 0 deletions packages/capture-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./network-provider-capture": {
"types": "./src/network-provider-capture.ts",
"default": "./src/network-provider-capture.ts"
},
"./android-replacement-surface-occlusion": {
"types": "./src/snapshot/android-replacement-surface-occlusion.ts",
"default": "./src/snapshot/android-replacement-surface-occlusion.ts"
Expand Down
Loading
Loading