diff --git a/packages/docs/docs/features/network.mdx b/packages/docs/docs/features/network.mdx index fc6d68498..e7cd516a1 100644 --- a/packages/docs/docs/features/network.mdx +++ b/packages/docs/docs/features/network.mdx @@ -88,12 +88,12 @@ 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. - 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, 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/docs/docs/reference/tools.mdx b/packages/docs/docs/reference/tools.mdx index 3c489191e..35b7481a3 100644 --- a/packages/docs/docs/reference/tools.mdx +++ b/packages/docs/docs/reference/tools.mdx @@ -118,7 +118,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/blueprints/native-devtools.ts b/packages/tool-server/src/blueprints/native-devtools.ts index 89e14ae97..07624ccd1 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; +const MAX_NETWORK_LOG_ENTRIES = 1000; /** * Why an app has no live devtools connection — and so what, if anything, @@ -487,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( @@ -499,6 +502,7 @@ export interface NativeDevtoolsApi { interface AppConnection { socket: net.Socket; networkLog: NetworkEvent[]; + networkLogEvicted: boolean; } function getNativeDevtoolsSocketPath(udid: string): string { @@ -644,7 +648,6 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint(); const pendingRpc = new Map< number, @@ -808,7 +811,7 @@ export const nativeDevtoolsBlueprint: ServiceBlueprint= MAX_LOG_ENTRIES) { + if (conn.networkLog.length >= 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 088f867be..55135df2c 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 @@ -15,14 +15,107 @@ const zodSchema = z.object({ bundleId: z.string().describe("Bundle ID of the app"), limit: z .number() + .int() + .nonnegative() .optional() .default(50) - .describe("Maximum number of events to return (most recent first)"), - clear: z.boolean().optional().default(false).describe("Clear the log after reading"), + .describe( + "Maximum number of requests (format 'requests') or raw events (format 'events') to return, most recent" + ), + format: z + .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. 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." + ), }); type Params = z.infer; -type Result = NativeDevtoolsPrecheckBlock | { status: "ok"; count: number; events: NetworkEvent[] }; +interface NativeRequestRow { + requestId: string; + method?: string; + url?: string; + finalUrl?: string; + status?: number; + mimeType?: string; + bytes?: number; + durationMs?: number; + error?: string; +} + +type Result = + | NativeDevtoolsPrecheckBlock + | { status: "ok"; count: number; events: NetworkEvent[]; truncated?: true } + | { + status: "ok"; + count: number; + totalRequests: number; + requests: NativeRequestRow[]; + 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(); + 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; + const finish = () => { + if (typeof p.encodedDataLength === "number") row.bytes = p.encodedDataLength; + 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": + 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; + if (row.url && p.response?.url && !sameUrl(p.response.url, row.url)) + row.finalUrl = p.response.url; + break; + case "Network.loadingFinished": + finish(); + break; + case "Network.loadingFailed": + row.error = p.errorText ?? "failed"; + finish(); + break; + } + } + return [...rows.values()].map(({ start: _start, ...row }) => row); +} + +export { summarizeRequests }; export const nativeNetworkLogsTool: ToolDefinition = { id: "native-network-logs", @@ -36,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, 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). 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, @@ -71,8 +164,24 @@ 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); + const evicted = api.networkLogEvicted(params.bundleId); if (params.clear) api.clearNetworkLog(params.bundleId); - return { status: "ok", count: events.length, events }; + // 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, ...truncated }; + } + const all = summarizeRequests(log); + const requests = mostRecent(all); + return { + status: "ok", + count: requests.length, + totalRequests: all.length, + requests, + ...truncated, + }; }, }; diff --git a/packages/tool-server/test/describe-tool.test.ts b/packages/tool-server/test/describe-tool.test.ts index 4947735b4..66fc2038d 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 7cd2381b4..5a27869ee 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 ffe9b9569..adbbcca75 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 51a1506f8..2a2ae2085 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-log-buffer.test.ts b/packages/tool-server/test/native-network-log-buffer.test.ts new file mode 100644 index 000000000..df7b3912e --- /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(); + } + }); +}); 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 000000000..d2f66abea --- /dev/null +++ b/packages/tool-server/test/native-network-summary.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { 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 rows = summarizeRequests([ + ev("Network.requestWillBeSent", { + requestId: "1", + timestamp: 10, + request: { method: "POST", url: "http://x/rpc/feed" }, + }), + ev("Network.responseReceived", { + requestId: "1", + response: { url: "http://x/rpc/feed", 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", + timestamp: 11.5, + errorText: "offline", + encodedDataLength: 0, + }), + ]); + 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", + 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("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).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); + }); + + 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]); + }); + + 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", () => { + 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 } = {}, + 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: () => flagged, + } 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 }); + expect((await cleared.result).count).toBe(3); + 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, + 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(); + }); + + 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); + }); +}); diff --git a/packages/tool-server/test/native-target-app.test.ts b/packages/tool-server/test/native-target-app.test.ts index 36b4c148e..f6d4eacf4 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 30e41f919..6d5337da9 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 40dcdecc4..5451dae44 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"); },