From 19c1849ef7ef8fdd25931f9e8dbdc1475e2d9f67 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Sat, 10 Oct 2026 13:43:11 +0200 Subject: [PATCH 01/10] feat(native-network-logs): one compact row per request by default The tool returned the raw Network.* events, mostly dataReceived chunks, so a screen of traffic came back as a wall of text that could overflow the tool output limit while still not listing every request. Default to one row per request (method, url, status, mimeType, bytes, durationMs, error), with limit counting requests; format: "events" keeps the raw stream. --- packages/docs/docs/features/network.mdx | 2 +- packages/docs/docs/reference/tools.mdx | 2 +- .../native-devtools/native-network-logs.ts | 80 +++++++++++++++++-- .../test/native-network-summary.test.ts | 45 +++++++++++ 4 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 packages/tool-server/test/native-network-summary.test.ts diff --git a/packages/docs/docs/features/network.mdx b/packages/docs/docs/features/network.mdx index bd08b79591..c3d0b89b53 100644 --- a/packages/docs/docs/features/network.mdx +++ b/packages/docs/docs/features/network.mdx @@ -57,6 +57,6 @@ Argent removes the bundle traffic and the HMR traffic of Metro from the record. - For Chromium, Argent reads the active tab. If you change the tab, Argent attaches to the new tab. If the page changed after the request, the body of the response can be unavailable. - The `NSURLProtocol` layer is available on iOS only. On Android, use the JS layer. - Argent does not replace the data in the bodies of the requests and of the responses. Protect a record of requests as you protect other data of the app. -- On the native `NSURLProtocol` layer, Argent does not replace the values of secret headers. The agent sees headers such as `Authorization` and `Cookie` in full. Protect a record of native requests as you protect other data of the app. +- On the native `NSURLProtocol` layer, the agent gets one short row for each request by default, without headers. When the agent asks for the raw events, Argent does not replace the values of secret headers. The agent sees headers such as `Authorization` and `Cookie` in full. Protect a record of native requests as you protect other data of the app. Argent does this with these three tools: `view-network-logs`, `view-network-request-details` and `native-network-logs`. The [tools reference](/docs/reference/tools) contains all tools. diff --git a/packages/docs/docs/reference/tools.mdx b/packages/docs/docs/reference/tools.mdx index 7a2388fe3d..91ef4f6a1f 100644 --- a/packages/docs/docs/reference/tools.mdx +++ b/packages/docs/docs/reference/tools.mdx @@ -101,7 +101,7 @@ Argent supports physical iPhones; physical iPads are not supported yet. `list-de | `native-find-views` | Search views by class, accessibility id, label, tag, or `nativeID` | | `native-view-at-point` | Inspect the deepest visible view at a native window point | | `native-user-interactable-view-at-point` | Inspect the deepest view at a point that would receive touch input | -| `native-network-logs` | Retrieve requests captured at the native `NSURLProtocol` level | +| `native-network-logs` | Requests captured at the native `NSURLProtocol` level, one row each | ## Chromium and Electron diff --git a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts index 4fda092e97..d177d6ebcf 100644 --- a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts +++ b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts @@ -17,12 +17,76 @@ const zodSchema = z.object({ .number() .optional() .default(50) - .describe("Maximum number of events to return (most recent first)"), + .describe( + "Maximum number of requests (format 'requests') or raw events (format 'events') to return, most recent" + ), + format: z + .enum(["requests", "events"]) + .optional() + .default("requests") + .describe( + "'requests' (default): one compact row per request — method, url, status, mimeType, bytes, durationMs, error. 'events': the raw CDP Network.* events (large: response bodies arrive as many dataReceived chunks)." + ), clear: z.boolean().optional().default(false).describe("Clear the log after reading"), }); type Params = z.infer; -type Result = NativeDevtoolsPrecheckBlock | { status: "ok"; count: number; events: NetworkEvent[] }; +interface NativeRequestRow { + requestId: string; + method?: string; + url?: string; + status?: number; + mimeType?: string; + bytes?: number; + durationMs?: number; + error?: string; +} + +type Result = + | NativeDevtoolsPrecheckBlock + | { status: "ok"; count: number; events: NetworkEvent[] } + | { status: "ok"; count: number; totalRequests: number; requests: NativeRequestRow[] }; + +/** + * Fold CDP Network.* events into one row per requestId. The raw stream is + * mostly `dataReceived` chunks (a video or bundle download is hundreds), so an + * agent asking for 400 events got ~98K chars and overflowed the tool-output + * limit while still not seeing all requests. + */ +function summarizeRequests(events: NetworkEvent[]): NativeRequestRow[] { + const rows = new Map(); + for (const e of events) { + const p = (e.params ?? {}) as Record; + const id = typeof p.requestId === "string" ? p.requestId : undefined; + if (!id) continue; + let row = rows.get(id); + if (!row) rows.set(id, (row = { requestId: id })); + const t = typeof p.timestamp === "number" ? p.timestamp : undefined; + switch (e.method) { + case "Network.requestWillBeSent": + row.method = p.request?.method; + row.url = p.request?.url; + row.start = t; + break; + case "Network.responseReceived": + row.status = p.response?.status; + row.mimeType = p.response?.mimeType; + row.url ??= p.response?.url; + break; + case "Network.loadingFinished": + if (typeof p.encodedDataLength === "number") row.bytes = p.encodedDataLength; + if (t !== undefined && row.start !== undefined) + row.durationMs = Math.round((t - row.start) * 1000); + break; + case "Network.loadingFailed": + row.error = p.errorText ?? "failed"; + break; + } + } + return [...rows.values()].map(({ start: _start, ...row }) => row); +} + +export { summarizeRequests }; export const nativeNetworkLogsTool: ToolDefinition = { id: "native-network-logs", @@ -36,7 +100,7 @@ export const nativeNetworkLogsTool: ToolDefinition = { description: `Retrieve network requests captured at the native NSURLProtocol level. Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that bypass JS fetch. Use when you need to inspect native-level HTTP traffic that is invisible to JS fetch interception. -Returns { status, count, events } where each event contains URL, method, status code, headers, and timing. +Returns { status, count, totalRequests, requests } with one row per request (method, url, status, mimeType, bytes, durationMs, error); pass format: "events" for the raw CDP events with headers. If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If the same status comes back after that restart, stop restarting: follow the message, which names the terminal fallback. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a few seconds and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. A not-running app comes back as one of those statuses rather than a failure. An app that is not connected does too, except on a device whose devtools agent argent attached to rather than armed itself — there it fails with \`NATIVE_DEVTOOLS_NOT_CONNECTED\`, since no restart of ours can complete a handshake we do not own. Failures are separate: an Apple system app is rejected outright (terminal — never retry it), while a missing host dependency or a udid that is not an Apple device is not.`, zodSchema, @@ -71,8 +135,14 @@ A not-running app comes back as one of those statuses rather than a failure. An api.activateNetworkInspection(params.bundleId); - const events = api.getNetworkLog(params.bundleId).slice(-params.limit); + const log = api.getNetworkLog(params.bundleId); if (params.clear) api.clearNetworkLog(params.bundleId); - return { status: "ok", count: events.length, events }; + if (params.format === "events") { + const events = log.slice(-params.limit); + return { status: "ok", count: events.length, events }; + } + const all = summarizeRequests(log); + const requests = all.slice(-params.limit); + return { status: "ok", count: requests.length, totalRequests: all.length, requests }; }, }; diff --git a/packages/tool-server/test/native-network-summary.test.ts b/packages/tool-server/test/native-network-summary.test.ts new file mode 100644 index 0000000000..06a008176a --- /dev/null +++ b/packages/tool-server/test/native-network-summary.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { summarizeRequests } from "../src/tools/native-devtools/native-network-logs"; + +describe("native-network-logs request summary", () => { + it("folds CDP events into one row per request", () => { + const ev = (method: string, params: Record) => ({ + method, + params, + timestamp: 0, + }); + const rows = summarizeRequests([ + ev("Network.requestWillBeSent", { + requestId: "1", + timestamp: 10, + request: { method: "POST", url: "http://x/rpc/feed" }, + }), + ev("Network.responseReceived", { + requestId: "1", + response: { status: 200, mimeType: "application/json" }, + }), + ...Array.from({ length: 50 }, () => + ev("Network.dataReceived", { requestId: "1", dataLength: 1000 }) + ), + ev("Network.loadingFinished", { requestId: "1", timestamp: 10.25, encodedDataLength: 50000 }), + ev("Network.requestWillBeSent", { + requestId: "2", + timestamp: 11, + request: { method: "GET", url: "http://x/down" }, + }), + ev("Network.loadingFailed", { requestId: "2", errorText: "offline" }), + ]); + expect(rows).toEqual([ + { + requestId: "1", + method: "POST", + url: "http://x/rpc/feed", + status: 200, + mimeType: "application/json", + bytes: 50000, + durationMs: 250, + }, + { requestId: "2", method: "GET", url: "http://x/down", error: "offline" }, + ]); + }); +}); From bb2139d730e3d79c828ef1173bbdbfa8f3ac8a67 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 12 Oct 2026 19:58:54 +0200 Subject: [PATCH 02/10] test(native-network-logs): pass the new format param in typed calls --- .../tool-server/test/external-native-devtools.test.ts | 8 +++++++- packages/tool-server/test/native-devtools-status.test.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/tool-server/test/external-native-devtools.test.ts b/packages/tool-server/test/external-native-devtools.test.ts index 338df6ea66..50fad49c0e 100644 --- a/packages/tool-server/test/external-native-devtools.test.ts +++ b/packages/tool-server/test/external-native-devtools.test.ts @@ -235,7 +235,13 @@ describe("a provider that lends its native-devtools agent", () => { await expect( nativeNetworkLogsTool.execute( { nativeDevtools: instance.api }, - { bundleId: "com.example.gone", clear: false, limit: 50, udid: DEVICE_ID } + { + bundleId: "com.example.gone", + clear: false, + limit: 50, + format: "requests", + udid: DEVICE_ID, + } ) ).rejects.toThrow(/not connected/); diff --git a/packages/tool-server/test/native-devtools-status.test.ts b/packages/tool-server/test/native-devtools-status.test.ts index 325bba3b86..7405c014f2 100644 --- a/packages/tool-server/test/native-devtools-status.test.ts +++ b/packages/tool-server/test/native-devtools-status.test.ts @@ -488,7 +488,7 @@ describe("native-* feature tools — the non-injectable throw propagates out of expectNotInjectableThrow(() => nativeNetworkLogsTool.execute( { nativeDevtools: mkApi() }, - { udid: U, bundleId: SYSTEM_APP, limit: 50, clear: false } + { udid: U, bundleId: SYSTEM_APP, limit: 50, format: "requests", clear: false } ) )); @@ -849,6 +849,7 @@ describe("precheckNativeDevtools maps a measured state to its remedy", () => { y: 1, className: "UIView", limit: 1, + format: "requests", clear: false, } )) as { status: string; message: string }; From 1d3fbb8b5f04df11c50fa74443bd3d7801ba6082 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Tue, 13 Oct 2026 15:45:55 +0200 Subject: [PATCH 03/10] fix(native-network-logs): keep redirect, failure and buffer facts in request rows - finalUrl when the response came from another URL; url stays what the app asked for - failed requests keep bytes and durationMs - truncated: true once the log hit its 1000-event limit - format no longer required in the params type - describe what each format carries (request bodies, never response bodies) - execute-level tests for format, limit, totalRequests, clear and truncated --- packages/docs/docs/features/network.mdx | 2 +- .../src/blueprints/native-devtools.ts | 4 +- .../native-devtools/native-network-logs.ts | 42 +++-- .../test/external-native-devtools.test.ts | 8 +- .../test/native-devtools-status.test.ts | 3 +- .../test/native-network-summary.test.ts | 176 +++++++++++++++++- 6 files changed, 198 insertions(+), 37 deletions(-) diff --git a/packages/docs/docs/features/network.mdx b/packages/docs/docs/features/network.mdx index c3d0b89b53..010761cdf2 100644 --- a/packages/docs/docs/features/network.mdx +++ b/packages/docs/docs/features/network.mdx @@ -57,6 +57,6 @@ Argent removes the bundle traffic and the HMR traffic of Metro from the record. - For Chromium, Argent reads the active tab. If you change the tab, Argent attaches to the new tab. If the page changed after the request, the body of the response can be unavailable. - The `NSURLProtocol` layer is available on iOS only. On Android, use the JS layer. - Argent does not replace the data in the bodies of the requests and of the responses. Protect a record of requests as you protect other data of the app. -- On the native `NSURLProtocol` layer, the agent gets one short row for each request by default, without headers. When the agent asks for the raw events, Argent does not replace the values of secret headers. The agent sees headers such as `Authorization` and `Cookie` in full. Protect a record of native requests as you protect other data of the app. +- On the native `NSURLProtocol` layer, the agent gets one short row for each request by default, without headers or request bodies. Neither form contains the bodies of responses. When the agent asks for the raw events, Argent does not replace the values of secret headers. The agent sees headers such as `Authorization` and `Cookie` in full. Protect a record of native requests as you protect other data of the app. Argent does this with these three tools: `view-network-logs`, `view-network-request-details` and `native-network-logs`. The [tools reference](/docs/reference/tools) contains all tools. diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 7bb8ae161f..7333718f70 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -90,6 +90,7 @@ export const NON_INJECTABLE_RECOVERY = // Max consecutive init failures per service instance before it stops retrying. export const MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS = 3; +export const MAX_NETWORK_LOG_ENTRIES = 1000; /** * Why an app has no live devtools connection — and so what, if anything, @@ -644,7 +645,6 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint(); const pendingRpc = new Map< number, @@ -827,7 +827,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint= MAX_LOG_ENTRIES) { + if (conn.networkLog.length >= MAX_NETWORK_LOG_ENTRIES) { conn.networkLog.shift(); } conn.networkLog.push({ diff --git a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts index d177d6ebcf..87b9a9d311 100644 --- a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts +++ b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { + MAX_NETWORK_LOG_ENTRIES, nativeDevtoolsRef, precheckNativeDevtools, type NativeDevtoolsApi, @@ -23,9 +24,8 @@ const zodSchema = z.object({ format: z .enum(["requests", "events"]) .optional() - .default("requests") .describe( - "'requests' (default): one compact row per request — method, url, status, mimeType, bytes, durationMs, error. 'events': the raw CDP Network.* events (large: response bodies arrive as many dataReceived chunks)." + "'requests' (default): one compact row per request — method, url, finalUrl, status, mimeType, bytes, durationMs, error. 'events': the raw CDP Network.* events, several per request, with request and response headers and the request body (request.postData). Neither format carries response bodies: dataReceived events hold only chunk sizes." ), clear: z.boolean().optional().default(false).describe("Clear the log after reading"), }); @@ -35,6 +35,7 @@ interface NativeRequestRow { requestId: string; method?: string; url?: string; + finalUrl?: string; status?: number; mimeType?: string; bytes?: number; @@ -45,14 +46,15 @@ interface NativeRequestRow { type Result = | NativeDevtoolsPrecheckBlock | { status: "ok"; count: number; events: NetworkEvent[] } - | { status: "ok"; count: number; totalRequests: number; requests: NativeRequestRow[] }; + | { + status: "ok"; + count: number; + totalRequests: number; + requests: NativeRequestRow[]; + truncated?: true; + }; -/** - * Fold CDP Network.* events into one row per requestId. The raw stream is - * mostly `dataReceived` chunks (a video or bundle download is hundreds), so an - * agent asking for 400 events got ~98K chars and overflowed the tool-output - * limit while still not seeing all requests. - */ +/** Fold CDP Network.* events into one row per requestId, in first-seen order. */ function summarizeRequests(events: NetworkEvent[]): NativeRequestRow[] { const rows = new Map(); for (const e of events) { @@ -62,6 +64,11 @@ function summarizeRequests(events: NetworkEvent[]): NativeRequestRow[] { let row = rows.get(id); if (!row) rows.set(id, (row = { requestId: id })); const t = typeof p.timestamp === "number" ? p.timestamp : undefined; + const finish = () => { + if (typeof p.encodedDataLength === "number") row.bytes = p.encodedDataLength; + if (t !== undefined && row.start !== undefined) + row.durationMs = Math.round((t - row.start) * 1000); + }; switch (e.method) { case "Network.requestWillBeSent": row.method = p.request?.method; @@ -71,15 +78,14 @@ function summarizeRequests(events: NetworkEvent[]): NativeRequestRow[] { case "Network.responseReceived": row.status = p.response?.status; row.mimeType = p.response?.mimeType; - row.url ??= p.response?.url; + if (p.response?.url && p.response.url !== row.url) row.finalUrl = p.response.url; break; case "Network.loadingFinished": - if (typeof p.encodedDataLength === "number") row.bytes = p.encodedDataLength; - if (t !== undefined && row.start !== undefined) - row.durationMs = Math.round((t - row.start) * 1000); + finish(); break; case "Network.loadingFailed": row.error = p.errorText ?? "failed"; + finish(); break; } } @@ -100,7 +106,7 @@ export const nativeNetworkLogsTool: ToolDefinition = { description: `Retrieve network requests captured at the native NSURLProtocol level. Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that bypass JS fetch. Use when you need to inspect native-level HTTP traffic that is invisible to JS fetch interception. -Returns { status, count, totalRequests, requests } with one row per request (method, url, status, mimeType, bytes, durationMs, error); pass format: "events" for the raw CDP events with headers. +Returns { status, count, totalRequests, requests } with one row per request (method, url, status, mimeType, bytes, durationMs, error). finalUrl is set when a redirect ended elsewhere; status and mimeType are then those of finalUrl. A row with no bytes, durationMs or error has not finished: it is still running, or the app cancelled it. truncated: true means the log reached its last-${MAX_NETWORK_LOG_ENTRIES}-events limit, so older requests are missing, totalRequests counts only what is left, and a row without method and url lost its first events. Pass format: "events" for the raw CDP events with headers and request bodies. If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If the same status comes back after that restart, stop restarting: follow the message, which names the terminal fallback. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a few seconds and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. A not-running app comes back as one of those statuses rather than a failure. An app that is not connected does too, except on a device whose devtools agent argent attached to rather than armed itself — there it fails with \`NATIVE_DEVTOOLS_NOT_CONNECTED\`, since no restart of ours can complete a handshake we do not own. Failures are separate: an Apple system app is rejected outright (terminal — never retry it), while a missing host dependency or a udid that is not an Apple device is not.`, zodSchema, @@ -143,6 +149,12 @@ A not-running app comes back as one of those statuses rather than a failure. An } const all = summarizeRequests(log); const requests = all.slice(-params.limit); - return { status: "ok", count: requests.length, totalRequests: all.length, requests }; + return { + status: "ok", + count: requests.length, + totalRequests: all.length, + requests, + ...(log.length >= MAX_NETWORK_LOG_ENTRIES && { truncated: true as const }), + }; }, }; diff --git a/packages/tool-server/test/external-native-devtools.test.ts b/packages/tool-server/test/external-native-devtools.test.ts index 50fad49c0e..338df6ea66 100644 --- a/packages/tool-server/test/external-native-devtools.test.ts +++ b/packages/tool-server/test/external-native-devtools.test.ts @@ -235,13 +235,7 @@ describe("a provider that lends its native-devtools agent", () => { await expect( nativeNetworkLogsTool.execute( { nativeDevtools: instance.api }, - { - bundleId: "com.example.gone", - clear: false, - limit: 50, - format: "requests", - udid: DEVICE_ID, - } + { bundleId: "com.example.gone", clear: false, limit: 50, udid: DEVICE_ID } ) ).rejects.toThrow(/not connected/); diff --git a/packages/tool-server/test/native-devtools-status.test.ts b/packages/tool-server/test/native-devtools-status.test.ts index 7405c014f2..325bba3b86 100644 --- a/packages/tool-server/test/native-devtools-status.test.ts +++ b/packages/tool-server/test/native-devtools-status.test.ts @@ -488,7 +488,7 @@ describe("native-* feature tools — the non-injectable throw propagates out of expectNotInjectableThrow(() => nativeNetworkLogsTool.execute( { nativeDevtools: mkApi() }, - { udid: U, bundleId: SYSTEM_APP, limit: 50, format: "requests", clear: false } + { udid: U, bundleId: SYSTEM_APP, limit: 50, clear: false } ) )); @@ -849,7 +849,6 @@ describe("precheckNativeDevtools maps a measured state to its remedy", () => { y: 1, className: "UIView", limit: 1, - format: "requests", clear: false, } )) as { status: string; message: string }; diff --git a/packages/tool-server/test/native-network-summary.test.ts b/packages/tool-server/test/native-network-summary.test.ts index 06a008176a..26460cfcdc 100644 --- a/packages/tool-server/test/native-network-summary.test.ts +++ b/packages/tool-server/test/native-network-summary.test.ts @@ -1,13 +1,42 @@ -import { describe, it, expect } from "vitest"; -import { summarizeRequests } from "../src/tools/native-devtools/native-network-logs"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + MAX_NETWORK_LOG_ENTRIES, + type NativeDevtoolsApi, + type NetworkEvent, +} from "../src/blueprints/native-devtools"; +import { + nativeNetworkLogsTool, + summarizeRequests, +} from "../src/tools/native-devtools/native-network-logs"; + +vi.mock("../src/blueprints/native-devtools", async () => { + const actual = await vi.importActual( + "../src/blueprints/native-devtools" + ); + return { ...actual, precheckNativeDevtools: vi.fn(async () => null) }; +}); + +const ev = (method: string, params: Record): NetworkEvent => ({ + method, + params, + timestamp: 0, +}); + +const request = (id: string, url: string, t: number): NetworkEvent[] => [ + ev("Network.requestWillBeSent", { + requestId: id, + timestamp: t, + request: { method: "GET", url }, + }), + ev("Network.responseReceived", { + requestId: id, + response: { url, status: 200, mimeType: "application/json" }, + }), + ev("Network.loadingFinished", { requestId: id, timestamp: t + 0.1, encodedDataLength: 10 }), +]; describe("native-network-logs request summary", () => { it("folds CDP events into one row per request", () => { - const ev = (method: string, params: Record) => ({ - method, - params, - timestamp: 0, - }); const rows = summarizeRequests([ ev("Network.requestWillBeSent", { requestId: "1", @@ -16,7 +45,7 @@ describe("native-network-logs request summary", () => { }), ev("Network.responseReceived", { requestId: "1", - response: { status: 200, mimeType: "application/json" }, + response: { url: "http://x/rpc/feed", status: 200, mimeType: "application/json" }, }), ...Array.from({ length: 50 }, () => ev("Network.dataReceived", { requestId: "1", dataLength: 1000 }) @@ -27,7 +56,12 @@ describe("native-network-logs request summary", () => { timestamp: 11, request: { method: "GET", url: "http://x/down" }, }), - ev("Network.loadingFailed", { requestId: "2", errorText: "offline" }), + ev("Network.loadingFailed", { + requestId: "2", + timestamp: 11.5, + errorText: "offline", + encodedDataLength: 0, + }), ]); expect(rows).toEqual([ { @@ -39,7 +73,129 @@ describe("native-network-logs request summary", () => { bytes: 50000, durationMs: 250, }, - { requestId: "2", method: "GET", url: "http://x/down", error: "offline" }, + { + requestId: "2", + method: "GET", + url: "http://x/down", + error: "offline", + bytes: 0, + durationMs: 500, + }, ]); }); + + it("keeps the requested url and adds finalUrl after a redirect", () => { + const [row] = summarizeRequests([ + ev("Network.requestWillBeSent", { + requestId: "1", + request: { method: "GET", url: "http://x/api/me" }, + }), + ev("Network.responseReceived", { + requestId: "1", + response: { url: "http://x/login", status: 200, mimeType: "text/html" }, + }), + ]); + expect(row).toEqual({ + requestId: "1", + method: "GET", + url: "http://x/api/me", + finalUrl: "http://x/login", + status: 200, + mimeType: "text/html", + }); + }); + + it("does not take the response url as url when the request event is gone", () => { + const [row] = summarizeRequests([ + ev("Network.responseReceived", { + requestId: "1", + response: { url: "http://x/login", status: 200, mimeType: "text/html" }, + }), + ]); + expect(row?.url).toBeUndefined(); + expect(row?.finalUrl).toBe("http://x/login"); + }); +}); + +describe("native-network-logs execute", () => { + const UDID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"; + const BUNDLE = "com.example.app"; + + beforeEach(async () => { + const { __resetDepCacheForTests, __primeDepCacheForTests } = + await import("../src/utils/check-deps"); + __resetDepCacheForTests(); + __primeDepCacheForTests(["xcrun"]); + }); + + const run = ( + log: NetworkEvent[], + params: { limit?: number; format?: "requests" | "events"; clear?: boolean } = {} + ) => { + const clearNetworkLog = vi.fn(); + const api = { + isConnected: () => true, + activateNetworkInspection: () => {}, + getNetworkLog: () => log, + clearNetworkLog, + } as unknown as NativeDevtoolsApi; + const result = nativeNetworkLogsTool.execute( + { nativeDevtools: api }, + { + udid: UDID, + bundleId: BUNDLE, + limit: params.limit ?? 50, + clear: params.clear ?? false, + format: params.format, + } + ); + return { result: result as Promise>, clearNetworkLog }; + }; + + const log = [ + ...request("a", "http://x/a", 1), + ...request("b", "http://x/b", 2), + ...request("c", "http://x/c", 3), + ]; + + it("returns request rows by default", async () => { + const out = await run(log).result; + expect(out.events).toBeUndefined(); + expect(out.requests.map((r: { url: string }) => r.url)).toEqual([ + "http://x/a", + "http://x/b", + "http://x/c", + ]); + expect(out).toMatchObject({ status: "ok", count: 3, totalRequests: 3 }); + expect(out.truncated).toBeUndefined(); + }); + + it("returns raw events with format events", async () => { + const out = await run(log, { format: "events", limit: 2 }).result; + expect(out.requests).toBeUndefined(); + expect(out).toMatchObject({ status: "ok", count: 2, events: log.slice(-2) }); + }); + + it("limits to the most recent requests and still counts them all", async () => { + const out = await run(log, { limit: 1 }).result; + expect(out).toMatchObject({ count: 1, totalRequests: 3 }); + expect(out.requests[0].url).toBe("http://x/c"); + }); + + it("clears the log only when asked", async () => { + const kept = run(log); + await kept.result; + expect(kept.clearNetworkLog).not.toHaveBeenCalled(); + const cleared = run(log, { clear: true }); + await cleared.result; + expect(cleared.clearNetworkLog).toHaveBeenCalledWith(BUNDLE); + }); + + it("flags a log that reached its size limit", async () => { + const full = Array.from({ length: MAX_NETWORK_LOG_ENTRIES }, () => + ev("Network.dataReceived", { requestId: "a", dataLength: 1 }) + ); + expect((await run(full).result).truncated).toBe(true); + expect((await run(full.slice(1)).result).truncated).toBeUndefined(); + }); }); From 5369d3566d324538aaf61c673677bc93b62e2d96 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Tue, 13 Oct 2026 15:55:03 +0200 Subject: [PATCH 04/10] fix(native-network-logs): keep the tool description a plain literal --- .../src/tools/native-devtools/native-network-logs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts index 87b9a9d311..06dd811350 100644 --- a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts +++ b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts @@ -106,7 +106,7 @@ export const nativeNetworkLogsTool: ToolDefinition = { description: `Retrieve network requests captured at the native NSURLProtocol level. Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that bypass JS fetch. Use when you need to inspect native-level HTTP traffic that is invisible to JS fetch interception. -Returns { status, count, totalRequests, requests } with one row per request (method, url, status, mimeType, bytes, durationMs, error). finalUrl is set when a redirect ended elsewhere; status and mimeType are then those of finalUrl. A row with no bytes, durationMs or error has not finished: it is still running, or the app cancelled it. truncated: true means the log reached its last-${MAX_NETWORK_LOG_ENTRIES}-events limit, so older requests are missing, totalRequests counts only what is left, and a row without method and url lost its first events. Pass format: "events" for the raw CDP events with headers and request bodies. +Returns { status, count, totalRequests, requests } with one row per request (method, url, status, mimeType, bytes, durationMs, error). finalUrl is set when a redirect ended elsewhere; status and mimeType are then those of finalUrl. A row with no bytes, durationMs or error has not finished: it is still running, or the app cancelled it. truncated: true means the log reached its 1000-event limit, so older requests are missing, totalRequests counts only what is left, and a row without method and url lost its first events. Pass format: "events" for the raw CDP events with headers and request bodies. If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help — restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If the same status comes back after that restart, stop restarting: follow the message, which names the terminal fallback. If status is connect_pending: the app is injected and still connecting — do not restart it, wait a few seconds and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised — follow the message (re-boot the simulator) rather than retrying this tool. A not-running app comes back as one of those statuses rather than a failure. An app that is not connected does too, except on a device whose devtools agent argent attached to rather than armed itself — there it fails with \`NATIVE_DEVTOOLS_NOT_CONNECTED\`, since no restart of ours can complete a handshake we do not own. Failures are separate: an Apple system app is rejected outright (terminal — never retry it), while a missing host dependency or a udid that is not an Apple device is not.`, zodSchema, From dff731d329c6b3c9b8c5a086a4ffc084752d1b96 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Thu, 22 Oct 2026 03:55:26 +0200 Subject: [PATCH 05/10] fix(native-network-logs): tighten finalUrl, limit and truncated in request rows - finalUrl only for a real redirect: compare parsed URLs, skip rows with no url - truncated: true only once the log evicted events, not when it is merely full - limit is a non-negative integer, and limit 0 returns no rows - say that most request bodies are missing and that clear drops the headers - tests for clear-after-read, fractional timestamps and limit 0 --- packages/docs/docs/features/network.mdx | 2 +- .../src/blueprints/native-devtools.ts | 14 ++- .../native-devtools/native-network-logs.ts | 40 +++++++-- .../tool-server/test/describe-tool.test.ts | 1 + .../tool-server/test/ios-launch-args.test.ts | 1 + .../test/launch-restart-system-app.test.ts | 1 + .../test/native-devtools-status.test.ts | 1 + .../test/native-network-summary.test.ts | 87 +++++++++++++++---- .../test/native-target-app.test.ts | 1 + .../simulator-watcher-external-claim.test.ts | 1 + .../simulator-watcher-init-failure.test.ts | 2 + 11 files changed, 121 insertions(+), 30 deletions(-) diff --git a/packages/docs/docs/features/network.mdx b/packages/docs/docs/features/network.mdx index 1898baad26..c8efe19615 100644 --- a/packages/docs/docs/features/network.mdx +++ b/packages/docs/docs/features/network.mdx @@ -94,6 +94,6 @@ Argent removes the bundle traffic and the HMR traffic of Metro from the network - For Chromium, Argent reads the active tab. If you change the tab, Argent attaches to the new tab. If the page changed after the request, the body of the response can be unavailable. - The `NSURLProtocol` layer is available on iOS only. On Android, use the JS runtime layer. - Argent does not replace the data in the bodies of the requests and of the responses. Protect a network log as you protect other data of the app. -- On the native `NSURLProtocol` layer, the agent gets one short row for each request by default, without headers or request bodies. Neither form contains the bodies of responses. When the agent asks for the raw events, Argent does not replace the values of secret headers. The agent sees headers such as `Authorization` and `Cookie` in full. Protect a native network log as you protect other data of the app. +- On the native `NSURLProtocol` layer, the agent gets one short row for each request by default, without headers. The agent can ask for the raw events, which contain the headers. Neither form contains the bodies of responses. The raw events contain the body of a request only if the app does not send the body as a stream, and most iOS requests send it as a stream. When the agent asks for the raw events, Argent does not replace the values of secret headers. The agent sees headers such as `Authorization` and `Cookie` in full. Protect a native network log as you protect other data of the app. Argent does this with these three tools: `view-network-logs`, `view-network-request-details` and `native-network-logs`. The [tools reference](/docs/reference/tools) contains all tools. diff --git a/packages/tool-server/src/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index dd8551f4b7..07624ccd16 100644 --- a/packages/tool-server/src/blueprints/native-devtools.ts +++ b/packages/tool-server/src/blueprints/native-devtools.ts @@ -90,7 +90,7 @@ export const NON_INJECTABLE_RECOVERY = // Max consecutive init failures per service instance before it stops retrying. export const MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS = 3; -export const MAX_NETWORK_LOG_ENTRIES = 1000; +const MAX_NETWORK_LOG_ENTRIES = 1000; /** * Why an app has no live devtools connection — and so what, if anything, @@ -488,6 +488,8 @@ export interface NativeDevtoolsApi { activateNetworkInspection(bundleId: string): void; getNetworkLog(bundleId: string): NetworkEvent[]; clearNetworkLog(bundleId: string): void; + /** True once the log dropped its oldest events to stay at MAX_NETWORK_LOG_ENTRIES; cleared with the log. */ + networkLogEvicted(bundleId: string): boolean; getAppState(bundleId: string): Promise; detectFrontmostBundleId(): Promise; queryViewHierarchy( @@ -500,6 +502,7 @@ export interface NativeDevtoolsApi { interface AppConnection { socket: net.Socket; networkLog: NetworkEvent[]; + networkLogEvicted: boolean; } function getNativeDevtoolsSocketPath(udid: string): string { @@ -808,7 +811,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint= MAX_NETWORK_LOG_ENTRIES) { conn.networkLog.shift(); + conn.networkLogEvicted = true; } conn.networkLog.push({ method: p.method, @@ -1087,9 +1091,13 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint { const conn = connections.get(bundleId); - if (conn) conn.networkLog.length = 0; + if (!conn) return; + conn.networkLog.length = 0; + conn.networkLogEvicted = false; }, + networkLogEvicted: (bundleId) => connections.get(bundleId)?.networkLogEvicted ?? false, + async getAppState(bundleId) { const result = (await sendViewInspectorRpc(bundleId, "Application.getState")) as { applicationState?: NativeApplicationState; diff --git a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts index 9c61aae004..a8f70a25bc 100644 --- a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts +++ b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { FAILURE_CODES, FailureError, type ToolDefinition } from "@argent/registry"; import { - MAX_NETWORK_LOG_ENTRIES, nativeDevtoolsRef, precheckNativeDevtools, type NativeDevtoolsApi, @@ -16,6 +15,8 @@ const zodSchema = z.object({ bundleId: z.string().describe("Bundle ID of the app"), limit: z .number() + .int() + .nonnegative() .optional() .default(50) .describe( @@ -25,9 +26,15 @@ const zodSchema = z.object({ .enum(["requests", "events"]) .optional() .describe( - "'requests' (default): one compact row per request — method, url, finalUrl, status, mimeType, bytes, durationMs, error. 'events': the raw CDP Network.* events, several per request, with request and response headers and the request body (request.postData). Neither format carries response bodies: dataReceived events hold only chunk sizes." + "'requests' (default): one compact row per request: method, url, finalUrl, status, mimeType, bytes, durationMs, error. 'events': the raw CDP Network.* events, several per request, with request and response headers. Neither format carries response bodies (dataReceived events hold only chunk sizes), and most request bodies are missing too: NSURLSession sends them as streams, so the event has only request.hasPostData: true." + ), + clear: z + .boolean() + .optional() + .default(false) + .describe( + "Clear the log after reading. The cleared events are gone, so with format 'requests' their headers cannot be read later: read with format 'events' first if you need them." ), - clear: z.boolean().optional().default(false).describe("Clear the log after reading"), }); type Params = z.infer; @@ -54,6 +61,19 @@ type Result = truncated?: true; }; +/** + * The inner NSURLSession reports the canonical URL (`https://x.com` comes back + * as `https://x.com/`, a host in lower case), so compare parsed forms: only a + * real redirect should produce a `finalUrl`. + */ +function sameUrl(a: string, b: string): boolean { + try { + return new URL(a).href === new URL(b).href; + } catch { + return a === b; + } +} + /** Fold CDP Network.* events into one row per requestId, in first-seen order. */ function summarizeRequests(events: NetworkEvent[]): NativeRequestRow[] { const rows = new Map(); @@ -78,7 +98,8 @@ function summarizeRequests(events: NetworkEvent[]): NativeRequestRow[] { case "Network.responseReceived": row.status = p.response?.status; row.mimeType = p.response?.mimeType; - if (p.response?.url && p.response.url !== row.url) row.finalUrl = p.response.url; + if (row.url && p.response?.url && !sameUrl(p.response.url, row.url)) + row.finalUrl = p.response.url; break; case "Network.loadingFinished": finish(); @@ -106,7 +127,7 @@ export const nativeNetworkLogsTool: ToolDefinition = { description: `Retrieve network requests captured at the native NSURLProtocol level. Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that never pass through JS XMLHttpRequest or fetch. Use when you need to inspect native-level HTTP traffic that is invisible to the JS-level capture. -Returns { status, count, totalRequests, requests } with one row per request (method, url, status, mimeType, bytes, durationMs, error). finalUrl is set when a redirect ended elsewhere; status and mimeType are then those of finalUrl. A row with no bytes, durationMs or error has not finished: it is still running, or the app cancelled it. truncated: true means the log reached its 1000-event limit, so older requests are missing, totalRequests counts only what is left, and a row without method and url lost its first events. Pass format: "events" for the raw CDP events with headers and request bodies. +Returns { status, count, totalRequests, requests } with one row per request (method, url, status, mimeType, bytes, durationMs, error). finalUrl is set when a redirect ended elsewhere; status and mimeType are then those of finalUrl. A row with no bytes, durationMs or error has not finished: it is still running, or the app cancelled it. truncated: true means the log reached its 1000-event limit, so older requests are missing, totalRequests counts only what is left, and A row without method and url lost its first events, to that limit or to a clear: true read while it was in flight. Pass format: "events" for the raw CDP events with headers (most request bodies are not captured, and response bodies never are). If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help. Restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If the same status comes back after that restart, stop restarting: follow the message, which names the terminal fallback. If status is connect_pending: the app is injected and still connecting. Do not restart it, wait a few seconds and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised. Follow the message (re-boot the simulator) rather than retrying this tool. A not-running app comes back as one of those statuses rather than a failure. An app that is not connected does too, except on a device whose devtools agent argent attached to rather than armed itself: there it fails with \`NATIVE_DEVTOOLS_NOT_CONNECTED\`, since no restart of ours can complete a handshake we do not own. Failures are separate: an Apple system app is rejected outright (terminal: never retry it), while a missing host dependency or a udid that is not an Apple device is not.`, zodSchema, @@ -142,19 +163,22 @@ A not-running app comes back as one of those statuses rather than a failure. An api.activateNetworkInspection(params.bundleId); const log = api.getNetworkLog(params.bundleId); + const evicted = api.networkLogEvicted(params.bundleId); if (params.clear) api.clearNetworkLog(params.bundleId); + // slice(-0) would return everything, so cut from the front instead. + const mostRecent = (items: T[]) => items.slice(Math.max(0, items.length - params.limit)); if (params.format === "events") { - const events = log.slice(-params.limit); + const events = mostRecent(log); return { status: "ok", count: events.length, events }; } const all = summarizeRequests(log); - const requests = all.slice(-params.limit); + const requests = mostRecent(all); return { status: "ok", count: requests.length, totalRequests: all.length, requests, - ...(log.length >= MAX_NETWORK_LOG_ENTRIES && { truncated: true as const }), + ...(evicted && { truncated: true as const }), }; }, }; diff --git a/packages/tool-server/test/describe-tool.test.ts b/packages/tool-server/test/describe-tool.test.ts index 4947735b4b..66fc2038d7 100644 --- a/packages/tool-server/test/describe-tool.test.ts +++ b/packages/tool-server/test/describe-tool.test.ts @@ -63,6 +63,7 @@ function makeNativeDevtoolsApi(options: { activateNetworkInspection: () => {}, getNetworkLog: () => [], clearNetworkLog: () => {}, + networkLogEvicted: () => false, getAppState: async (bundleId) => ({ bundleId, applicationState: "active", diff --git a/packages/tool-server/test/ios-launch-args.test.ts b/packages/tool-server/test/ios-launch-args.test.ts index 7cd2381b4c..5a27869ee9 100644 --- a/packages/tool-server/test/ios-launch-args.test.ts +++ b/packages/tool-server/test/ios-launch-args.test.ts @@ -47,6 +47,7 @@ function makeNativeApi(): NativeDevtoolsApi { activateNetworkInspection: () => {}, getNetworkLog: () => [], clearNetworkLog: () => {}, + networkLogEvicted: () => false, getAppState: async () => { throw new Error("not implemented"); }, diff --git a/packages/tool-server/test/launch-restart-system-app.test.ts b/packages/tool-server/test/launch-restart-system-app.test.ts index ffe9b9569a..adbbcca75e 100644 --- a/packages/tool-server/test/launch-restart-system-app.test.ts +++ b/packages/tool-server/test/launch-restart-system-app.test.ts @@ -60,6 +60,7 @@ function makeNativeApi(): NativeDevtoolsApi { activateNetworkInspection: () => {}, getNetworkLog: () => [], clearNetworkLog: () => {}, + networkLogEvicted: () => false, getAppState: async () => { throw new Error("not implemented"); }, diff --git a/packages/tool-server/test/native-devtools-status.test.ts b/packages/tool-server/test/native-devtools-status.test.ts index 51a1506f86..2a2ae20855 100644 --- a/packages/tool-server/test/native-devtools-status.test.ts +++ b/packages/tool-server/test/native-devtools-status.test.ts @@ -78,6 +78,7 @@ function makeNativeApi(options: { activateNetworkInspection: () => {}, getNetworkLog: () => [], clearNetworkLog: () => {}, + networkLogEvicted: () => false, getAppState: async () => { throw new Error("not implemented"); }, diff --git a/packages/tool-server/test/native-network-summary.test.ts b/packages/tool-server/test/native-network-summary.test.ts index 26460cfcdc..9a9b79fdea 100644 --- a/packages/tool-server/test/native-network-summary.test.ts +++ b/packages/tool-server/test/native-network-summary.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { - MAX_NETWORK_LOG_ENTRIES, - type NativeDevtoolsApi, - type NetworkEvent, -} from "../src/blueprints/native-devtools"; +import { type NativeDevtoolsApi, type NetworkEvent } from "../src/blueprints/native-devtools"; import { nativeNetworkLogsTool, summarizeRequests, @@ -105,15 +101,52 @@ describe("native-network-logs request summary", () => { }); }); - it("does not take the response url as url when the request event is gone", () => { + it("sets neither url nor finalUrl when the request event is gone", () => { const [row] = summarizeRequests([ ev("Network.responseReceived", { requestId: "1", response: { url: "http://x/login", status: 200, mimeType: "text/html" }, }), ]); - expect(row?.url).toBeUndefined(); - expect(row?.finalUrl).toBe("http://x/login"); + expect(row).toEqual({ requestId: "1", status: 200, mimeType: "text/html" }); + }); + + it("does not treat a canonicalized url as a redirect", () => { + const rows = summarizeRequests([ + ev("Network.requestWillBeSent", { + requestId: "1", + request: { method: "GET", url: "https://example.com" }, + }), + ev("Network.responseReceived", { + requestId: "1", + response: { url: "https://example.com/", status: 200 }, + }), + ev("Network.requestWillBeSent", { + requestId: "2", + request: { method: "GET", url: "http://LOCALHOST:8765/x" }, + }), + ev("Network.responseReceived", { + requestId: "2", + response: { url: "http://localhost:8765/x", status: 200 }, + }), + ]); + expect(rows.map((r) => r.finalUrl)).toEqual([undefined, undefined]); + }); + + it("rounds fractional dylib timestamps to whole milliseconds", () => { + const [row] = summarizeRequests([ + ev("Network.requestWillBeSent", { + requestId: "1", + timestamp: 1760000000.1, + request: { method: "GET", url: "http://x/a" }, + }), + ev("Network.loadingFinished", { + requestId: "1", + timestamp: 1760000000.35333, + encodedDataLength: 1, + }), + ]); + expect(row?.durationMs).toBe(253); }); }); @@ -130,14 +163,19 @@ describe("native-network-logs execute", () => { const run = ( log: NetworkEvent[], - params: { limit?: number; format?: "requests" | "events"; clear?: boolean } = {} + params: { limit?: number; format?: "requests" | "events"; clear?: boolean } = {}, + evicted = false ) => { - const clearNetworkLog = vi.fn(); + const stored = [...log]; + const clearNetworkLog = vi.fn(() => { + stored.length = 0; + }); const api = { isConnected: () => true, activateNetworkInspection: () => {}, - getNetworkLog: () => log, + getNetworkLog: () => [...stored], clearNetworkLog, + networkLogEvicted: () => evicted, } as unknown as NativeDevtoolsApi; const result = nativeNetworkLogsTool.execute( { nativeDevtools: api }, @@ -187,15 +225,28 @@ describe("native-network-logs execute", () => { await kept.result; expect(kept.clearNetworkLog).not.toHaveBeenCalled(); const cleared = run(log, { clear: true }); - await cleared.result; + expect((await cleared.result).count).toBe(3); expect(cleared.clearNetworkLog).toHaveBeenCalledWith(BUNDLE); }); - it("flags a log that reached its size limit", async () => { - const full = Array.from({ length: MAX_NETWORK_LOG_ENTRIES }, () => - ev("Network.dataReceived", { requestId: "a", dataLength: 1 }) - ); - expect((await run(full).result).truncated).toBe(true); - expect((await run(full.slice(1)).result).truncated).toBeUndefined(); + it("returns no rows for limit 0 but still counts them", async () => { + expect(await run(log, { limit: 0 }).result).toMatchObject({ + count: 0, + totalRequests: 3, + requests: [], + }); + expect((await run(log, { limit: 0, format: "events" }).result).events).toEqual([]); + }); + + it("rejects a negative or fractional limit", () => { + const base = { udid: UDID, bundleId: BUNDLE }; + const schema = nativeNetworkLogsTool.zodSchema!; + expect(schema.safeParse({ ...base, limit: -1 }).success).toBe(false); + expect(schema.safeParse({ ...base, limit: 1.5 }).success).toBe(false); + }); + + it("flags a log only once it evicted events", async () => { + expect((await run(log, {}, true).result).truncated).toBe(true); + expect((await run(log, {}, false).result).truncated).toBeUndefined(); }); }); diff --git a/packages/tool-server/test/native-target-app.test.ts b/packages/tool-server/test/native-target-app.test.ts index 36b4c148ef..f6d4eacf48 100644 --- a/packages/tool-server/test/native-target-app.test.ts +++ b/packages/tool-server/test/native-target-app.test.ts @@ -36,6 +36,7 @@ function makeApi(apps: NativeAppState[]): NativeDevtoolsApi { activateNetworkInspection: () => {}, getNetworkLog: () => [], clearNetworkLog: () => {}, + networkLogEvicted: () => false, getAppState: async (bundleId) => { const app = byBundleId.get(bundleId); if (!app) throw new Error(`unknown bundleId: ${bundleId}`); diff --git a/packages/tool-server/test/simulator-watcher-external-claim.test.ts b/packages/tool-server/test/simulator-watcher-external-claim.test.ts index 30e41f9198..6d5337da90 100644 --- a/packages/tool-server/test/simulator-watcher-external-claim.test.ts +++ b/packages/tool-server/test/simulator-watcher-external-claim.test.ts @@ -114,6 +114,7 @@ function makeApi(): NativeDevtoolsApi { activateNetworkInspection: () => {}, getNetworkLog: () => [], clearNetworkLog: () => {}, + networkLogEvicted: () => false, getAppState: async () => { throw new Error("not implemented"); }, diff --git a/packages/tool-server/test/simulator-watcher-init-failure.test.ts b/packages/tool-server/test/simulator-watcher-init-failure.test.ts index 40dcdecc46..5451dae449 100644 --- a/packages/tool-server/test/simulator-watcher-init-failure.test.ts +++ b/packages/tool-server/test/simulator-watcher-init-failure.test.ts @@ -76,6 +76,7 @@ function makeFailingApi(): { api: NativeDevtoolsApi; ensureCalls: () => number } activateNetworkInspection: () => {}, getNetworkLog: () => [], clearNetworkLog: () => {}, + networkLogEvicted: () => false, getAppState: async () => { throw new Error("not implemented"); }, @@ -180,6 +181,7 @@ describe("simulator-watcher with api-owned init failure state", () => { activateNetworkInspection: () => {}, getNetworkLog: () => [], clearNetworkLog: () => {}, + networkLogEvicted: () => false, getAppState: async () => { throw new Error("not implemented"); }, From 42a718b2103b7e00854d600a71995a00c4ca602d Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 26 Oct 2026 00:59:16 +0100 Subject: [PATCH 06/10] fix(native-network-logs): use the dylib's duration when a row lost its request event --- .../native-devtools/native-network-logs.ts | 2 ++ .../test/native-network-summary.test.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts index a8f70a25bc..c972f8f8a8 100644 --- a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts +++ b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts @@ -88,6 +88,8 @@ function summarizeRequests(events: NetworkEvent[]): NativeRequestRow[] { if (typeof p.encodedDataLength === "number") row.bytes = p.encodedDataLength; if (t !== undefined && row.start !== undefined) row.durationMs = Math.round((t - row.start) * 1000); + else if (typeof p.nativeDevtoolsDurationMs === "number") + row.durationMs = Math.round(p.nativeDevtoolsDurationMs); }; switch (e.method) { case "Network.requestWillBeSent": diff --git a/packages/tool-server/test/native-network-summary.test.ts b/packages/tool-server/test/native-network-summary.test.ts index 9a9b79fdea..423ced705c 100644 --- a/packages/tool-server/test/native-network-summary.test.ts +++ b/packages/tool-server/test/native-network-summary.test.ts @@ -148,6 +148,25 @@ describe("native-network-logs request summary", () => { ]); expect(row?.durationMs).toBe(253); }); + + it("takes the dylib's duration when the request event is gone", () => { + const rows = summarizeRequests([ + ev("Network.loadingFinished", { + requestId: "1", + timestamp: 20, + encodedDataLength: 13, + nativeDevtoolsDurationMs: 5012.4, + }), + ev("Network.loadingFailed", { + requestId: "2", + timestamp: 21, + errorText: "timed out", + encodedDataLength: 0, + nativeDevtoolsDurationMs: 60000, + }), + ]); + expect(rows.map((r) => r.durationMs)).toEqual([5012, 60000]); + }); }); describe("native-network-logs execute", () => { From ff409acfd1705b2dd3af178fb4c86fb72e4dcf11 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 26 Oct 2026 01:23:07 +0100 Subject: [PATCH 07/10] fix(native-network-logs): measure durationMs from the dylib's duration whenever it is present The loadingFinished timestamp is stamped after the dylib's delivery queue, so the requestWillBeSent difference grew with chunk count while rows without a start used the dylib's network-completion duration. Prefer nativeDevtoolsDurationMs for every row so both cases measure the same interval, and fall back to the timestamp difference only when it is missing. --- .../native-devtools/native-network-logs.ts | 6 +++--- .../test/native-network-summary.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts index c972f8f8a8..501fdb11c9 100644 --- a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts +++ b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts @@ -86,10 +86,10 @@ function summarizeRequests(events: NetworkEvent[]): NativeRequestRow[] { const t = typeof p.timestamp === "number" ? p.timestamp : undefined; const finish = () => { if (typeof p.encodedDataLength === "number") row.bytes = p.encodedDataLength; - if (t !== undefined && row.start !== undefined) - row.durationMs = Math.round((t - row.start) * 1000); - else if (typeof p.nativeDevtoolsDurationMs === "number") + if (typeof p.nativeDevtoolsDurationMs === "number") row.durationMs = Math.round(p.nativeDevtoolsDurationMs); + else if (t !== undefined && row.start !== undefined) + row.durationMs = Math.round((t - row.start) * 1000); }; switch (e.method) { case "Network.requestWillBeSent": diff --git a/packages/tool-server/test/native-network-summary.test.ts b/packages/tool-server/test/native-network-summary.test.ts index 423ced705c..ee11f330ce 100644 --- a/packages/tool-server/test/native-network-summary.test.ts +++ b/packages/tool-server/test/native-network-summary.test.ts @@ -167,6 +167,23 @@ describe("native-network-logs request summary", () => { ]); expect(rows.map((r) => r.durationMs)).toEqual([5012, 60000]); }); + + it("prefers the dylib's duration over the timestamp difference", () => { + const [row] = summarizeRequests([ + ev("Network.requestWillBeSent", { + requestId: "1", + timestamp: 10, + request: { method: "GET", url: "http://x/big" }, + }), + ev("Network.loadingFinished", { + requestId: "1", + timestamp: 10.236, + encodedDataLength: 1048576, + nativeDevtoolsDurationMs: 213.4, + }), + ]); + expect(row?.durationMs).toBe(213); + }); }); describe("native-network-logs execute", () => { From 44fd683a596d9157575477b0a512cd6a5160da26 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 26 Oct 2026 11:58:53 +0100 Subject: [PATCH 08/10] fix(native-network-logs): flag a truncated log in format events too --- packages/docs/docs/features/network.mdx | 2 +- .../tools/native-devtools/native-network-logs.ts | 9 +++++---- .../tool-server/test/native-network-summary.test.ts | 13 ++++++++++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/docs/docs/features/network.mdx b/packages/docs/docs/features/network.mdx index c8efe19615..e7cd516a1d 100644 --- a/packages/docs/docs/features/network.mdx +++ b/packages/docs/docs/features/network.mdx @@ -88,7 +88,7 @@ Argent removes the bundle traffic and the HMR traffic of Metro from the network - If the app aborts a library request before the network module of React Native gives the request its ID, the request still runs. The app gets an abort error, but the record shows the finished request and its response. - If the app does not read the body of a successful Expo `fetch()` response, the record has no body and no size. A response that cannot have a body, for example the response to a `HEAD` request, has the size 0. - Argent finds the network module of React Native in the list of modules that Metro adds to a development build. Without this list, Argent does not record the requests that a library sends to the network module itself. -- The network log keeps the most recent records only. The limits are 2000 records for the JS runtime layer and 1000 records for Chromium. For native iOS, the limit is 1000 protocol events. A request produces several events, so the native log holds fewer than 1000 requests. +- The network log keeps the most recent records only. The limits are 2000 records for the JS runtime layer and 1000 records for Chromium. For native iOS, the limit is 1000 protocol events. A request produces several events, so the native log holds fewer than 1000 requests. When the native log drops old events, both the request rows and the raw events are marked `truncated`. - The JS runtime layer does not read a binary response body, for example an image, a font, a PDF file or an `application/octet-stream` body. The record gives the type and the size of the body only. For Chromium, Argent does not show a response body that is not UTF-8 text. The details give the type and the size of this body only. - The JS runtime layer keeps approximately the first 1 MB of each body. The JS runtime layer also removes the oldest records when their bodies contain more than 50 MB of text. - For Chromium, Argent reads the active tab. If you change the tab, Argent attaches to the new tab. If the page changed after the request, the body of the response can be unavailable. diff --git a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts index 501fdb11c9..55135df2c7 100644 --- a/packages/tool-server/src/tools/native-devtools/native-network-logs.ts +++ b/packages/tool-server/src/tools/native-devtools/native-network-logs.ts @@ -52,7 +52,7 @@ interface NativeRequestRow { type Result = | NativeDevtoolsPrecheckBlock - | { status: "ok"; count: number; events: NetworkEvent[] } + | { status: "ok"; count: number; events: NetworkEvent[]; truncated?: true } | { status: "ok"; count: number; @@ -129,7 +129,7 @@ export const nativeNetworkLogsTool: ToolDefinition = { description: `Retrieve network requests captured at the native NSURLProtocol level. Unlike the JS-level network inspector (view-network-logs), this captures ALL network traffic from the app including native modules, Swift/Objective-C networking, and background transfers that never pass through JS XMLHttpRequest or fetch. Use when you need to inspect native-level HTTP traffic that is invisible to the JS-level capture. -Returns { status, count, totalRequests, requests } with one row per request (method, url, status, mimeType, bytes, durationMs, error). finalUrl is set when a redirect ended elsewhere; status and mimeType are then those of finalUrl. A row with no bytes, durationMs or error has not finished: it is still running, or the app cancelled it. truncated: true means the log reached its 1000-event limit, so older requests are missing, totalRequests counts only what is left, and A row without method and url lost its first events, to that limit or to a clear: true read while it was in flight. Pass format: "events" for the raw CDP events with headers (most request bodies are not captured, and response bodies never are). +Returns { status, count, totalRequests, requests } with one row per request (method, url, status, mimeType, bytes, durationMs, error). finalUrl is set when a redirect ended elsewhere; status and mimeType are then those of finalUrl. A row with no bytes, durationMs or error has not finished: it is still running, or the app cancelled it. truncated: true means the log reached its 1000-event limit, so older requests are missing, totalRequests counts only what is left, and A row without method and url lost its first events, to that limit or to a clear: true read while it was in flight. Pass format: "events" for the raw CDP events with headers (most request bodies are not captured, and response bodies never are); it returns { status, count, events } plus the same truncated: true when the oldest events, usually the requestWillBeSent ones holding request headers, were dropped. If status is restart_required: follow the message (usually restart-app), then retry. If status is service_stale: the app is already injected, so restarting it cannot help. Restart the tool-server (\`argent server stop && argent server start --detach\`) and retry. If the same status comes back after that restart, stop restarting: follow the message, which names the terminal fallback. If status is connect_pending: the app is injected and still connecting. Do not restart it, wait a few seconds and retry. If status is init_failed: the simulator's native-devtools environment could not be initialised. Follow the message (re-boot the simulator) rather than retrying this tool. A not-running app comes back as one of those statuses rather than a failure. An app that is not connected does too, except on a device whose devtools agent argent attached to rather than armed itself: there it fails with \`NATIVE_DEVTOOLS_NOT_CONNECTED\`, since no restart of ours can complete a handshake we do not own. Failures are separate: an Apple system app is rejected outright (terminal: never retry it), while a missing host dependency or a udid that is not an Apple device is not.`, zodSchema, @@ -169,9 +169,10 @@ A not-running app comes back as one of those statuses rather than a failure. An if (params.clear) api.clearNetworkLog(params.bundleId); // slice(-0) would return everything, so cut from the front instead. const mostRecent = (items: T[]) => items.slice(Math.max(0, items.length - params.limit)); + const truncated = evicted ? { truncated: true as const } : {}; if (params.format === "events") { const events = mostRecent(log); - return { status: "ok", count: events.length, events }; + return { status: "ok", count: events.length, events, ...truncated }; } const all = summarizeRequests(log); const requests = mostRecent(all); @@ -180,7 +181,7 @@ A not-running app comes back as one of those statuses rather than a failure. An count: requests.length, totalRequests: all.length, requests, - ...(evicted && { truncated: true as const }), + ...truncated, }; }, }; diff --git a/packages/tool-server/test/native-network-summary.test.ts b/packages/tool-server/test/native-network-summary.test.ts index ee11f330ce..ed134dccac 100644 --- a/packages/tool-server/test/native-network-summary.test.ts +++ b/packages/tool-server/test/native-network-summary.test.ts @@ -203,15 +203,17 @@ describe("native-network-logs execute", () => { evicted = false ) => { const stored = [...log]; + let flagged = evicted; const clearNetworkLog = vi.fn(() => { stored.length = 0; + flagged = false; }); const api = { isConnected: () => true, activateNetworkInspection: () => {}, getNetworkLog: () => [...stored], clearNetworkLog, - networkLogEvicted: () => evicted, + networkLogEvicted: () => flagged, } as unknown as NativeDevtoolsApi; const result = nativeNetworkLogsTool.execute( { nativeDevtools: api }, @@ -285,4 +287,13 @@ describe("native-network-logs execute", () => { expect((await run(log, {}, true).result).truncated).toBe(true); expect((await run(log, {}, false).result).truncated).toBeUndefined(); }); + + it("flags evicted events in format events too", async () => { + expect((await run(log, { format: "events" }, true).result).truncated).toBe(true); + expect((await run(log, { format: "events" }, false).result).truncated).toBeUndefined(); + }); + + it("still flags a log it clears while reading", async () => { + expect((await run(log, { clear: true }, true).result).truncated).toBe(true); + }); }); From 300527c07793cb9da568bae263ceda1f4ab9c187 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 26 Oct 2026 11:58:53 +0100 Subject: [PATCH 09/10] test(native-devtools): cover the network log ring buffer through a real agent connection --- .../test/native-network-log-buffer.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 packages/tool-server/test/native-network-log-buffer.test.ts diff --git a/packages/tool-server/test/native-network-log-buffer.test.ts b/packages/tool-server/test/native-network-log-buffer.test.ts new file mode 100644 index 0000000000..df7b3912e7 --- /dev/null +++ b/packages/tool-server/test/native-network-log-buffer.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from "vitest"; +import * as net from "node:net"; +import type { DeviceInfo } from "@argent/registry"; + +vi.mock("@argent/native-devtools-ios", () => ({ + bootstrapDylibPath: () => "/fake/dylibs/libArgentInjectionBootstrap.dylib", + bootstrapDylibPathTcp: () => "/fake/dylibs/tcp/libArgentInjectionBootstrap.dylib", + bootstrapDylibPathTvos: () => "/fake/dylibs/tvos/libArgentInjectionBootstrap.dylib", + tcpInjectionDylibs: () => [], + axServiceBinaryPath: () => "/fake/ax-service", + axServiceBinaryPathTcp: () => "/fake/ax-service-tcp", +})); + +type ExecCb = (err: Error | null, out: { stdout: string; stderr: string }) => void; + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: (_cmd: string, _args: readonly string[], opts: unknown, cb?: ExecCb) => { + const callback = (typeof opts === "function" ? opts : cb!) as ExecCb; + callback(null, { stdout: "", stderr: "" }); + }, + }; +}); + +import { nativeDevtoolsBlueprint, type NativeDevtoolsApi } from "../src/blueprints/native-devtools"; +import { processScopedUdid } from "./helpers/process-scoped-udid"; + +const UDID = processScopedUdid("-5555-6666-7777-888888888888"); +const BUNDLE = "com.example.app"; +const LOG_LIMIT = 1000; + +const device: DeviceInfo = { id: UDID, platform: "ios", kind: "simulator" }; + +function frame(type: string, payload: unknown): string { + return JSON.stringify({ type, payload }) + "\n"; +} + +function cdpEvent(index: number): string { + return frame("CDP", { + method: "Network.dataReceived", + params: { requestId: `r${index}`, dataLength: 1 }, + }); +} + +function sendEvents(sock: net.Socket, from: number, to: number) { + const lines: string[] = []; + for (let i = from; i < to; i++) lines.push(cdpEvent(i)); + sock.write(lines.join("")); +} + +function requestIds(api: NativeDevtoolsApi): string[] { + return api.getNetworkLog(BUNDLE).map((e) => (e.params as { requestId: string }).requestId); +} + +async function waitForLastEvent(api: NativeDevtoolsApi, index: number) { + await vi.waitFor(() => expect(requestIds(api).at(-1)).toBe(`r${index}`)); +} + +function connectAgent(socketPath: string): Promise { + return new Promise((resolve) => { + const sock = net.createConnection(socketPath, () => { + sock.write(frame("Control", { bundleId: BUNDLE })); + resolve(sock); + }); + }); +} + +describe.skipIf(process.platform === "win32")("native-devtools network log ring buffer", () => { + it("drops the oldest event past the limit, flags it, and resets on clear", async () => { + const instance = await nativeDevtoolsBlueprint.factory({}, device, { device }); + const api = instance.api as NativeDevtoolsApi; + const sock = await connectAgent(api.socketPath); + try { + await vi.waitFor(() => expect(api.isConnected(BUNDLE)).toBe(true)); + + sendEvents(sock, 0, LOG_LIMIT); + await waitForLastEvent(api, LOG_LIMIT - 1); + expect(api.getNetworkLog(BUNDLE)).toHaveLength(LOG_LIMIT); + expect(api.networkLogEvicted(BUNDLE)).toBe(false); + + sendEvents(sock, LOG_LIMIT, LOG_LIMIT + 1); + await waitForLastEvent(api, LOG_LIMIT); + const ids = requestIds(api); + expect(ids).toHaveLength(LOG_LIMIT); + expect(ids[0]).toBe("r1"); + expect(api.networkLogEvicted(BUNDLE)).toBe(true); + + const snapshot = api.getNetworkLog(BUNDLE); + api.clearNetworkLog(BUNDLE); + expect(snapshot).toHaveLength(LOG_LIMIT); + expect(api.getNetworkLog(BUNDLE)).toEqual([]); + expect(api.networkLogEvicted(BUNDLE)).toBe(false); + } finally { + sock.destroy(); + await instance.dispose(); + } + }); +}); From 3c1312bb6141423059e195105dee17f9c336f06b Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 26 Oct 2026 21:18:32 +0100 Subject: [PATCH 10/10] test(native-network-logs): cover the limit clamp and overlapping request order --- .../test/native-network-summary.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/tool-server/test/native-network-summary.test.ts b/packages/tool-server/test/native-network-summary.test.ts index ed134dccac..d2f66abea6 100644 --- a/packages/tool-server/test/native-network-summary.test.ts +++ b/packages/tool-server/test/native-network-summary.test.ts @@ -267,6 +267,23 @@ describe("native-network-logs execute", () => { expect(cleared.clearNetworkLog).toHaveBeenCalledWith(BUNDLE); }); + it("returns every request when there are fewer than limit", async () => { + const many = Array.from({ length: 30 }, (_, i) => request(`r${i}`, `http://x/${i}`, i)).flat(); + const out = await run(many).result; + expect(out).toMatchObject({ count: 30, totalRequests: 30 }); + expect(out.requests[0].url).toBe("http://x/0"); + }); + + it("orders overlapping requests by when they started", async () => { + const [startA, responseA, finishA] = request("a", "http://x/a", 1); + const [startB, responseB, finishB] = request("b", "http://x/b", 2); + const overlapping = [startA, startB, responseB, finishB, responseA, finishA]; + const all = await run(overlapping).result; + expect(all.requests.map((r: { url: string }) => r.url)).toEqual(["http://x/a", "http://x/b"]); + const latest = await run(overlapping, { limit: 1 }).result; + expect(latest.requests.map((r: { url: string }) => r.url)).toEqual(["http://x/b"]); + }); + it("returns no rows for limit 0 but still counts them", async () => { expect(await run(log, { limit: 0 }).result).toMatchObject({ count: 0,