diff --git a/CHANGELOG.md b/CHANGELOG.md index 214df69a91..36ee97535c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,20 @@ disclosing that through `truncated`/`effectiveDepth` as it does unscoped. - Fixed: repeated unfiltered Android snapshots stay compact when identical element bounds arrive with a different property order. Changes to the bounds still re-emit the tree. +- Fixed: iOS `network dump` no longer omits requests that reused a keep-alive connection. + CFNetwork logs a request URL only on the line that opens a connection, so a second request to + the same host produced no `url:` line and was dropped from the dump entirely — an "this endpoint + was called" check read as a definite fail. Such a request is now reported against the origin its + connection was opened for, with `pathUnavailable` set, its status, and its timing. A reused + request whose connection was opened before the scanned window cannot be named at all; those are + counted in the dump's `unnamedRequests`, so an empty result still reports that traffic was + observed. The identities behind that count reconcile the app-log and recovery windows internally + — so overlapping traffic is not double-counted and disjoint traffic is not under-reported — but + the response carries only the count, which stays bounded however large the scan window was. The notes say absence of an endpoint does not prove it was not called. +- Fixed: a URL logged as a delimited `url: ,` field no longer keeps the separator the log + format put after it, so an entry's `url` compares equal to the endpoint under test. A bare URL + elsewhere is left alone, since nothing there establishes that trailing punctuation is not part of + the path. - Added: `replay export` supports flows that switch apps and return, preserving each `open ` target as an explicit Maestro `launchApp.appId`. - Added: `replay export` converts recorded `home` actions to Maestro `pressKey: Home`, allowing diff --git a/packages/capture-kit/src/index.ts b/packages/capture-kit/src/index.ts index 9248fc1546..f83799095b 100644 --- a/packages/capture-kit/src/index.ts +++ b/packages/capture-kit/src/index.ts @@ -29,4 +29,8 @@ export { appLogSessionArtifactsMatch, assertAppLogSessionArtifacts, } from './app-log-session-artifacts.ts'; -export { mergeNetworkDumps, readRecentNetworkTrafficFromText } from './network-traffic.ts'; +export { + mergeNetworkScans, + readRecentNetworkTrafficFromText, + type NetworkScan, +} from './network-traffic.ts'; diff --git a/packages/capture-kit/src/network-traffic-android.test.ts b/packages/capture-kit/src/network-traffic-android.test.ts index 79617c4292..ff7af97815 100644 --- a/packages/capture-kit/src/network-traffic-android.test.ts +++ b/packages/capture-kit/src/network-traffic-android.test.ts @@ -3,7 +3,7 @@ import { test } from 'vitest'; import { readRecentNetworkTrafficFromText } from './network-traffic.ts'; test('preserves Android adjacent packet enrichment', () => { - const dump = readRecentNetworkTrafficFromText( + const { dump } = readRecentNetworkTrafficFromText( [ '03-31 17:43:32.564 V/GIBSDK (17434): [NetworkAgent]: packet id 23911610 added, queue size: 1', '03-31 17:43:32.700 V/OtherTag (17434): unrelated line 1', diff --git a/packages/capture-kit/src/network-traffic.test.ts b/packages/capture-kit/src/network-traffic.test.ts index 5a349d886f..48e9cc93db 100644 --- a/packages/capture-kit/src/network-traffic.test.ts +++ b/packages/capture-kit/src/network-traffic.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { mergeNetworkDumps, readRecentNetworkTrafficFromText } from './network-traffic.ts'; +import { mergeNetworkScans, readRecentNetworkTrafficFromText } from './network-traffic.ts'; test('parses the existing include projections and newest-first order', () => { - const dump = readRecentNetworkTrafficFromText( + const { dump } = readRecentNetworkTrafficFromText( [ '2026-02-24T10:00:00Z GET https://api.example.com/profile status=200', '2026-02-24T10:00:02Z {"method":"POST","url":"https://api.example.com/login","statusCode":401,"headers":{"x-id":"abc"},"requestBody":{"email":"u@example.com"},"responseBody":{"error":"denied"}}', @@ -35,7 +35,7 @@ test('parses the existing include projections and newest-first order', () => { }); test('keeps missing canonical app-log text distinct and merges recovery first', () => { - const missing = readRecentNetworkTrafficFromText('', { + const { dump: missing } = readRecentNetworkTrafficFromText('', { path: '/sessions/one/app.log', exists: false, backend: 'android', @@ -65,17 +65,18 @@ test('keeps missing canonical app-log text distinct and merges recovery first', scannedLines: 0, matchedLines: 0, entries: [], + unnamedRequests: 0, include: 'summary', limits: { maxEntries: 2, maxPayloadChars: 2048, maxScanLines: 100 }, }); assert.deepEqual( - mergeNetworkDumps(recovered, stale, 2).entries.map(({ url }) => url), + mergeNetworkScans(recovered, stale, 2).dump.entries.map(({ url }) => url), ['https://fresh.example.test', 'https://stale.example.test'], ); }); test('keeps Android adjacent enrichment disabled for Apple backends', () => { - const dump = readRecentNetworkTrafficFromText( + const { dump } = readRecentNetworkTrafficFromText( [ '2026-03-31 17:43:33.031 response code: 200', '2026-03-31 17:43:33.032 URL: https://api.example.com/fixture', @@ -96,7 +97,7 @@ test('keeps Android adjacent enrichment disabled for Apple backends', () => { }); test('ignores documentation URLs without an explicit network signal', () => { - const dump = readRecentNetworkTrafficFromText( + const { dump } = readRecentNetworkTrafficFromText( '2026-04-02 08:14:44Z config warning. See https://docs.example.test/setup for help.\n', { path: '/sessions/one/app.log', @@ -123,7 +124,7 @@ test('applies a validated absolute line offset to host-selected text', () => { maxScanLines: 100, }; - const dump = readRecentNetworkTrafficFromText('GET https://example.test status=200', { + const { dump } = readRecentNetworkTrafficFromText('GET https://example.test status=200', { ...options, lineNumberOffset: 5000, }); @@ -137,3 +138,213 @@ test('applies a validated absolute line offset to host-selected text', () => { /non-negative integer/, ); }); + +test('a URL logged mid-sentence drops the separator that follows it', () => { + const line = + '2026-09-09 18:22:27.805 Df spicygolf[33656:4505afd] [com.apple.network:connection] [C9 Hostname#c6f77afc:3040 tcp, url: http://localhost:3040/v4/messages/en_US, definite, attribution: developer] start'; + const { dump } = readRecentNetworkTrafficFromText(`${line}\n`, { + path: 'app.log', + exists: true, + backend: 'ios-simulator', + }); + + assert.equal(dump.entries[0]?.url, 'http://localhost:3040/v4/messages/en_US'); +}); + +// Captured from a real iOS simulator app log: `/v4/messages/en_US` opens +// connection 9 and logs its URL, then `/init` reuses connection 9 ~350ms later +// and CFNetwork logs no URL for it anywhere. +const CONNECTION_START = + '2026-09-09 18:22:27.805 Df spicygolf[33656:4505afd] [com.apple.network:connection] [C9 EA66F890-BE05-450D-BF6E-ADE5ADAC1CB8 Hostname#c6f77afc:3040 tcp, url: http://localhost:3040/v4/messages/en_US, definite, attribution: developer] start'; +const OPENING_SUMMARY = + '2026-09-09 18:22:27.816 Df spicygolf[33656:4505aed] [com.apple.CFNetwork:Summary] Task <10B2F1BA-8C9E-4877-80D2-994F1C3ED74A>.<1> summary for task success {transaction_duration_ms=11, response_status=200, connection=9, protocol="http/1.1", request_bytes=221, response_bytes=1214, cache_hit=true}'; +const REUSED_SUMMARY = + '2026-09-09 18:22:28.167 Df spicygolf[33656:4505ae4] [com.apple.CFNetwork:Summary] Task <2FAEF670-BB27-42A4-ACDD-6B6DF7D11510>.<2> summary for task success {transaction_duration_ms=1, response_status=200, connection=9, reused=1, reused_after_ms=0, request_bytes=236, response_bytes=624, cache_hit=true}'; + +function iosScan(lines: readonly string[]) { + return readRecentNetworkTrafficFromText(`${lines.join('\n')}\n`, { + path: 'app.log', + exists: true, + backend: 'ios-simulator', + }); +} + +function iosDump(lines: readonly string[]) { + return iosScan(lines).dump; +} + +test('a request that reused a keep-alive connection is reported against its origin', () => { + const dump = iosDump([CONNECTION_START, OPENING_SUMMARY, REUSED_SUMMARY]); + const reused = dump.entries.find((entry) => entry.pathUnavailable); + + assert.equal(reused?.url, 'http://localhost:3040'); + assert.equal(reused?.status, 200); + assert.equal(reused?.durationMs, 1); + assert.equal(reused?.timestamp, '2026-09-09 18:22:28.167'); +}); + +test('a task that opened its own connection is read from its URL-bearing line only', () => { + const dump = iosDump([CONNECTION_START, OPENING_SUMMARY]); + + assert.deepEqual( + dump.entries.map((entry) => entry.url), + ['http://localhost:3040/v4/messages/en_US'], + ); + assert.equal(dump.entries[0]?.pathUnavailable, undefined); +}); + +test('a reused request whose connection is outside the scanned window is not invented', () => { + const dump = iosDump([REUSED_SUMMARY]); + + assert.deepEqual(dump.entries, []); +}); + +test('a recycled connection number resolves to the origin most recently opened for it', () => { + const laterStart = CONNECTION_START.replace( + 'url: http://localhost:3040/v4/messages/en_US', + 'url: https://api.example.test/v1/session', + ); + const dump = iosDump([CONNECTION_START, laterStart, REUSED_SUMMARY]); + + assert.equal( + dump.entries.find((entry) => entry.pathUnavailable)?.url, + 'https://api.example.test', + ); +}); + +test('a reused request that never got a status drops the CFNetwork sentinel', () => { + const failure = REUSED_SUMMARY.replace( + 'summary for task success', + 'summary for task failure', + ).replace('response_status=200', 'response_status=-1'); + const dump = iosDump([CONNECTION_START, failure]); + const reused = dump.entries.find((entry) => entry.pathUnavailable); + + assert.equal(reused?.url, 'http://localhost:3040'); + assert.equal(reused?.status, undefined); +}); + +test('android dumps do not pay for CFNetwork correlation', () => { + const lines = `${[CONNECTION_START, REUSED_SUMMARY].join('\n')}\n`; + assert.equal(iosDump([CONNECTION_START, REUSED_SUMMARY]).entries.length, 2); + + const { dump } = readRecentNetworkTrafficFromText(lines, { + path: 'app.log', + exists: true, + backend: 'android', + }); + + assert.deepEqual( + dump.entries.map((entry) => entry.url), + ['http://localhost:3040/v4/messages/en_US'], + ); + assert.equal(dump.unnamedRequests, 0); +}); + +test('a reused request whose connection opened before the window is counted, not dropped', () => { + const dump = iosDump([REUSED_SUMMARY]); + + assert.deepEqual(dump.entries, []); + assert.equal(dump.unnamedRequests, 1); +}); + +test('a resolved reused request is named, not counted as unnamed', () => { + const dump = iosDump([CONNECTION_START, OPENING_SUMMARY, REUSED_SUMMARY]); + + assert.equal(dump.unnamedRequests, 0); + assert.equal(dump.entries.filter((entry) => entry.pathUnavailable).length, 1); +}); + +function withProcess(line: string, process: string): string { + const swapped = line.replace(/spicygolf\[\d+:[0-9a-f]+\]/, process); + if (swapped === line) throw new Error('fixture process token not found'); + return swapped; +} + +test('a recycled connection number does not inherit the origin of a previous process', () => { + const relaunchedSummary = REUSED_SUMMARY.replace( + 'spicygolf[33656:4505ae4]', + 'spicygolf[40001:4505ae4]', + ); + const dump = iosDump([CONNECTION_START, relaunchedSummary]); + + assert.deepEqual( + dump.entries.filter((entry) => entry.pathUnavailable), + [], + ); + assert.equal(dump.unnamedRequests, 1); +}); + +test('a connection number is resolved within the process that opened it', () => { + const otherProcessStart = withProcess(CONNECTION_START, 'otherapp[40001:4505afd]').replace( + 'url: http://localhost:3040/v4/messages/en_US', + 'url: https://wrong.example.test/x', + ); + const dump = iosDump([otherProcessStart, CONNECTION_START, REUSED_SUMMARY]); + + assert.equal(dump.entries.find((entry) => entry.pathUnavailable)?.url, 'http://localhost:3040'); +}); + +test('a line with no readable process identity leaves its traffic unnamed', () => { + const dump = iosDump([ + CONNECTION_START.replace('spicygolf[33656:4505afd]', 'spicygolf'), + REUSED_SUMMARY.replace('spicygolf[33656:4505ae4]', 'spicygolf'), + ]); + + assert.deepEqual( + dump.entries.filter((entry) => entry.pathUnavailable), + [], + ); + assert.equal(dump.unnamedRequests, 1); +}); + +test('a URL whose path ends in punctuation is not truncated into a different endpoint', () => { + const dump = iosDump([ + '2026-09-09 18:22:27.805 Df app[1:2] [com.example:Default] GET https://example.test/release. status=200', + ]); + + assert.equal(dump.entries[0]?.url, 'https://example.test/release.'); +}); + +test('a delimited url: field drops the separator the format put after it', () => { + const dump = iosDump([CONNECTION_START]); + + assert.equal(dump.entries[0]?.url, 'http://localhost:3040/v4/messages/en_US'); +}); + +// A second reused request on the same connection, distinct from REUSED_SUMMARY. +const SECOND_REUSED_SUMMARY = REUSED_SUMMARY.replace( + 'Task <2FAEF670-BB27-42A4-ACDD-6B6DF7D11510>.<2>', + 'Task <9C1D77B4-0E52-4A18-9D31-7F0A2B4C6E88>.<3>', +); + +test('two windows over disjoint unnamed traffic report both requests, not the larger count', () => { + const appLog = iosScan([REUSED_SUMMARY]); + const recovery = iosScan([SECOND_REUSED_SUMMARY]); + + const merged = mergeNetworkScans(recovery, appLog, 200); + + assert.equal(merged.dump.unnamedRequests, 2); +}); + +test('two windows over the same unnamed request report it once', () => { + const appLog = iosScan([REUSED_SUMMARY, SECOND_REUSED_SUMMARY]); + const recovery = iosScan([SECOND_REUSED_SUMMARY]); + + const merged = mergeNetworkScans(recovery, appLog, 200); + + assert.equal(merged.dump.unnamedRequests, 2); +}); + +test('a request one window named is not still counted as unnamed from the other', () => { + const appLog = iosScan([REUSED_SUMMARY]); + const recovery = iosScan([CONNECTION_START, REUSED_SUMMARY]); + + assert.equal(appLog.dump.unnamedRequests, 1); + assert.equal(recovery.dump.unnamedRequests, 0); + + const merged = mergeNetworkScans(recovery, appLog, 200); + + assert.equal(merged.dump.unnamedRequests, 0); + assert.equal(merged.dump.entries.filter((entry) => entry.pathUnavailable).length, 1); +}); diff --git a/packages/capture-kit/src/network-traffic.ts b/packages/capture-kit/src/network-traffic.ts index 8af95ae48a..197b7a156d 100644 --- a/packages/capture-kit/src/network-traffic.ts +++ b/packages/capture-kit/src/network-traffic.ts @@ -21,32 +21,81 @@ import { const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const; const METHOD_WITH_URL_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b\\s+https?:\\/\\/`, 'i'); const URL_REGEX = /https?:\/\/[^\s"'<>\])]+/i; +const CFNETWORK_CONNECTION_URL = /\[C(\d+)\b[^\]]*?\burl:\s*([^\s,\]]+)/; +const CFNETWORK_TASK_SUMMARY = /\bsummary for task (?:success|failure)\s*\{([^}]*)\}/; +// `Task .` identifies one request across every line it appears on, +// so the same request seen in two scan windows reconciles to one. +const CFNETWORK_TASK_ID = /\bTask\s+<([0-9A-Fa-f-]+)>\.<(\d+)>/; +// `name[pid:tid]` in the compact unified-log prefix. Connection numbers restart +// per process, so a number alone would let a relaunched app inherit the origin +// its predecessor opened; the pid is what keeps those apart. +const LOG_PROCESS_IDENTITY = /(?:^|\s)(\S+)\[(\d+):[0-9a-f]+\]/; +// `url: ,` is a delimited field, so the separator belongs to the format +// rather than to the URL. A bare URL elsewhere keeps whatever it matched, since +// nothing there establishes that trailing punctuation is not part of the path. +const URL_FIELD = /\burl:\s*(https?:\/\/[^\s,\]]+)/i; -export function mergeNetworkDumps( - primary: NetworkDump, - secondary: NetworkDump, - maxEntries = primary.limits.maxEntries, -): NetworkDump { - const entries = [...primary.entries]; +/** Connection openings in scan order, so a recycled number resolves to its most recent opening. */ +type CfNetworkConnectionIndex = ReadonlyMap< + string, + readonly Readonly<{ lineIndex: number; origin: string }>[] +>; + +/** + * A scan's public dump, and the identities behind its `unnamedRequests`. + * + * The identities exist to reconcile two scan windows and have no place in a + * response, where their number tracks the log rather than the caller's entry + * limit. They sit beside the dump rather than on it so that a route returning + * `scan.dump` cannot carry them out by accident: every producer of a dump is a + * response boundary, and this is the one shape that does not rely on each of + * them remembering. + */ +export type NetworkScan = Readonly<{ + dump: NetworkDump; + unnamedRequestIds: readonly string[]; +}>; + +export function mergeNetworkScans( + primary: NetworkScan, + secondary: NetworkScan, + maxEntries = primary.dump.limits.maxEntries, +): NetworkScan { + const entries = [...primary.dump.entries]; const seen = new Set(entries.map(networkEntryKey)); - for (const entry of secondary.entries) { + for (const entry of secondary.dump.entries) { const key = networkEntryKey(entry); if (seen.has(key)) continue; seen.add(key); entries.push(entry); if (entries.length >= maxEntries) break; } + // The two windows can cover different, overlapping, or disjoint traffic. A + // request either window named is named, and the rest union by identity, so + // neither window's blind spot inflates or masks the other's. + const named = new Set( + [...primary.dump.entries, ...secondary.dump.entries] + .map((entry) => entry.packetId) + .filter((id): id is string => id !== undefined), + ); + const unnamedRequestIds = [ + ...new Set([...primary.unnamedRequestIds, ...secondary.unnamedRequestIds]), + ].filter((id) => !named.has(id)); return Object.freeze({ - ...primary, - matchedLines: entries.length, - entries: Object.freeze(entries), + dump: Object.freeze({ + ...primary.dump, + matchedLines: entries.length, + entries: Object.freeze(entries), + unnamedRequests: unnamedRequestIds.length, + }), + unnamedRequestIds: Object.freeze(unnamedRequestIds), }); } export function readRecentNetworkTrafficFromText( content: string, options: NetworkDumpParserOptions, -): NetworkDump { +): NetworkScan { const maxEntries = clampInt(options.maxEntries, 25, 1, 200); const include = options.include ?? 'summary'; const maxPayloadChars = clampInt(options.maxPayloadChars, 2048, 64, 16_384); @@ -54,19 +103,29 @@ export function readRecentNetworkTrafficFromText( const lineNumberOffset = requireLineNumberOffset(options.lineNumberOffset); if (!options.exists) { return Object.freeze({ - path: options.path, - exists: false, - scannedLines: 0, - matchedLines: 0, - entries: Object.freeze([]), - include, - limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + dump: Object.freeze({ + path: options.path, + exists: false, + scannedLines: 0, + matchedLines: 0, + entries: Object.freeze([]), + unnamedRequests: 0, + include, + limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + }), + unnamedRequestIds: Object.freeze([]), }); } const allLines = content.split('\n'); const startIndex = Math.max(0, allLines.length - maxScanLines); const lines = allLines.slice(startIndex); const entries: NetworkEntry[] = []; + const cfNetworkConnections = isAppleBackend(options.backend) + ? indexCfNetworkConnections(lines) + : undefined; + const unnamedRequestIds = cfNetworkConnections + ? collectUnnamedCfNetworkTasks(lines, cfNetworkConnections) + : []; for (let i = lines.length - 1; i >= 0 && entries.length < maxEntries; i -= 1) { if (!lines[i]?.trim()) continue; const parsed = parseNetworkLine( @@ -76,20 +135,29 @@ export function readRecentNetworkTrafficFromText( options.backend, include, maxPayloadChars, + cfNetworkConnections, ); if (parsed) entries.push(parsed); } return Object.freeze({ - path: options.path, - exists: true, - scannedLines: lines.length, - matchedLines: entries.length, - entries: Object.freeze(entries), - include, - limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + dump: Object.freeze({ + path: options.path, + exists: true, + scannedLines: lines.length, + matchedLines: entries.length, + entries: Object.freeze(entries), + unnamedRequests: unnamedRequestIds.length, + include, + limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + }), + unnamedRequestIds: Object.freeze(unnamedRequestIds), }); } +function isAppleBackend(backend: LogBackend | undefined): boolean { + return backend === 'ios-simulator' || backend === 'ios-device' || backend === 'macos'; +} + function requireLineNumberOffset(value: number | undefined): number { if (value === undefined) return 0; if (!Number.isInteger(value) || value < 0) { @@ -105,11 +173,16 @@ function parseNetworkLine( backend: LogBackend | undefined, include: NetworkDump['include'], maxPayloadChars: number, + cfNetworkConnections: CfNetworkConnectionIndex | undefined, ): NetworkEntry | null { const line = lines[lineIndex]?.trim(); if (!line) return null; const maybeJson = parseEmbeddedNetworkJson(line); - const identity = parseNetworkIdentity(line, maybeJson); + const identity = + parseNetworkIdentity(line, maybeJson) ?? + (cfNetworkConnections + ? parseCfNetworkReusedTaskIdentity(line, cfNetworkConnections, lineIndex) + : null); if (!identity) return null; const result = createNetworkEntry(line, lineNumber, identity, maxPayloadChars); if (backend === 'android') enrichNetworkEntryFromAndroidLines(result, lines, lineIndex); @@ -121,6 +194,9 @@ type NetworkIdentity = Readonly<{ method?: string; url: string; status?: number; + durationMs?: number; + pathUnavailable?: boolean; + packetId?: string; }>; function parseNetworkIdentity( @@ -152,7 +228,9 @@ function parseNetworkUrl( line: string, maybeJson: Record | null, ): string | undefined { - return readNetworkJsonString(maybeJson, ['url', 'requestUrl']) ?? URL_REGEX.exec(line)?.[0]; + const json = readNetworkJsonString(maybeJson, ['url', 'requestUrl']); + if (json) return json; + return URL_FIELD.exec(line)?.[1] ?? URL_REGEX.exec(line)?.[0]; } function parseNetworkStatus( @@ -189,8 +267,8 @@ function createNetworkEntry( return { ...identity, timestamp: parseNetworkTimestamp(line), - packetId: parseAndroidPacketId(line) ?? undefined, - durationMs: parseAndroidDurationMs(line) ?? undefined, + packetId: identity.packetId ?? parseAndroidPacketId(line) ?? undefined, + durationMs: identity.durationMs ?? parseAndroidDurationMs(line) ?? undefined, raw: truncate(line, maxPayloadChars), line: lineNumber, }; @@ -241,3 +319,144 @@ function clampInt(value: number | undefined, fallback: number, min: number, max: ? fallback : Math.max(min, Math.min(max, value)); } + +/** + * CFNetwork logs a request URL only on the `com.apple.network:connection` line + * that opens a connection. A request that reuses a keep-alive connection emits + * a task summary with status, timing, and byte counts but no URL anywhere, so + * a URL-keyed reader drops it and an "endpoint was never called" check reads as + * a definite negative. Resolving the summary against the connection it reused + * recovers the origin; the request path is not in the log at all. + */ +function indexCfNetworkConnections(lines: readonly string[]): CfNetworkConnectionIndex { + const index = new Map(); + for (const [lineIndex, line] of lines.entries()) { + const match = CFNETWORK_CONNECTION_URL.exec(line); + if (!match) continue; + const key = cfNetworkConnectionKey(line, match[1] as string); + const origin = key === undefined ? undefined : readCfNetworkOrigin(match[2] as string); + if (!origin || key === undefined) continue; + const openings = index.get(key); + if (openings) openings.push({ lineIndex, origin }); + else index.set(key, [{ lineIndex, origin }]); + } + return index; +} + +/** + * A connection is only the same connection within one process. A line whose + * process cannot be read correlates to nothing, so its traffic stays unnamed + * rather than borrowing an origin the app never contacted. + */ +function cfNetworkConnectionKey(line: string, connection: string): string | undefined { + const process = LOG_PROCESS_IDENTITY.exec(line); + if (!process) return undefined; + return `${process[1]}[${process[2]}]#${connection}`; +} + +function parseCfNetworkReusedTaskIdentity( + line: string, + index: CfNetworkConnectionIndex, + lineIndex: number, +): NetworkIdentity | null { + const summary = CFNETWORK_TASK_SUMMARY.exec(line); + if (!summary) return null; + const fields = readCfNetworkSummaryFields(summary[1] as string); + // Without `reused` the task opened its own connection, so a URL-bearing line + // for it is already in the log and this summary would only duplicate it. + if (fields.get('reused') !== '1') return null; + const connection = fields.get('connection'); + const key = connection === undefined ? undefined : cfNetworkConnectionKey(line, connection); + const origin = key === undefined ? undefined : resolveCfNetworkOrigin(index, key, lineIndex); + if (!origin) return null; + return { + url: origin, + status: readCfNetworkStatus(fields.get('response_status')), + durationMs: readCfNetworkCount(fields.get('transaction_duration_ms')), + pathUnavailable: true, + packetId: cfNetworkTaskId(line), + }; +} + +function collectUnnamedCfNetworkTasks( + lines: readonly string[], + index: CfNetworkConnectionIndex, +): string[] { + const unnamed = new Set(); + for (const [lineIndex, line] of lines.entries()) { + const task = unnamedCfNetworkTaskOn(line, index, lineIndex); + if (task !== undefined) unnamed.add(task); + } + return [...unnamed]; +} + +/** The identity of a reused task on this line that resolves to no origin. */ +function unnamedCfNetworkTaskOn( + line: string, + index: CfNetworkConnectionIndex, + lineIndex: number, +): string | undefined { + if (!line.includes('summary for task')) return undefined; + const summary = CFNETWORK_TASK_SUMMARY.exec(line); + if (!summary) return undefined; + const fields = readCfNetworkSummaryFields(summary[1] as string); + if (fields.get('reused') !== '1') return undefined; + const connection = fields.get('connection'); + const key = connection === undefined ? undefined : cfNetworkConnectionKey(line, connection); + if (key !== undefined && resolveCfNetworkOrigin(index, key, lineIndex)) return undefined; + return cfNetworkTaskId(line); +} + +/** One request's identity, scoped to its process so a relaunch cannot alias it. */ +function cfNetworkTaskId(line: string): string | undefined { + const task = CFNETWORK_TASK_ID.exec(line); + if (!task) return undefined; + const process = LOG_PROCESS_IDENTITY.exec(line); + const scope = process ? `${process[1]}[${process[2]}]` : ''; + return `${scope}#${task[1]}.${task[2]}`; +} + +function resolveCfNetworkOrigin( + index: CfNetworkConnectionIndex, + key: string, + lineIndex: number, +): string | undefined { + const openings = index.get(key); + if (!openings) return undefined; + let resolved: string | undefined; + for (const opening of openings) { + if (opening.lineIndex > lineIndex) break; + resolved = opening.origin; + } + return resolved; +} + +function readCfNetworkSummaryFields(body: string): ReadonlyMap { + const fields = new Map(); + for (const pair of body.split(',')) { + const separator = pair.indexOf('='); + if (separator === -1) continue; + fields.set(pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()); + } + return fields; +} + +function readCfNetworkOrigin(url: string): string | undefined { + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + +// CFNetwork reports `-1` for a task that never received a response status. +function readCfNetworkStatus(value: string | undefined): number | undefined { + const status = readCfNetworkCount(value); + return status !== undefined && status > 0 ? status : undefined; +} + +function readCfNetworkCount(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} diff --git a/packages/contracts/src/network-log.ts b/packages/contracts/src/network-log.ts index a561d8c1e5..a0de7e105a 100644 --- a/packages/contracts/src/network-log.ts +++ b/packages/contracts/src/network-log.ts @@ -13,6 +13,11 @@ export type NetworkEntry = { headers?: string; requestBody?: string; responseBody?: string; + /** + * The reader observed this request but not its path: `url` is the origin of + * the connection it reused. Absent means `url` is the request URL as logged. + */ + pathUnavailable?: boolean; raw: string; line: number; }; diff --git a/packages/contracts/src/network-traffic.ts b/packages/contracts/src/network-traffic.ts index c9185c3a01..faf6c135c1 100644 --- a/packages/contracts/src/network-traffic.ts +++ b/packages/contracts/src/network-traffic.ts @@ -20,6 +20,14 @@ export type NetworkDump = Readonly<{ scannedLines: number; matchedLines: number; entries: readonly NetworkEntry[]; + /** + * How many requests the reader observed but could not name at all, so they + * are absent from `entries`: an empty dump with a non-zero count is a failed + * capture, not evidence that nothing was requested. A count rather than the + * identities behind it, so the response stays bounded however many lines the + * scan window held. + */ + unnamedRequests?: number; include: NonNullable; limits: Readonly<{ maxEntries: number; maxPayloadChars: number; maxScanLines: number }>; }>; diff --git a/packages/platform-android/src/network/runtime.ts b/packages/platform-android/src/network/runtime.ts index 3799e53755..c6373fe05b 100644 --- a/packages/platform-android/src/network/runtime.ts +++ b/packages/platform-android/src/network/runtime.ts @@ -4,7 +4,7 @@ import type { } from '@agent-device/contracts/platform-runtime-host'; import type { NetworkDumpInput, NetworkDumpResult } from '@agent-device/contracts/network-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; -import { mergeNetworkDumps, readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; +import { mergeNetworkScans, readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { assertAndroidLogPackageSafe } from '../logs/package-name.ts'; @@ -20,7 +20,7 @@ export async function dumpAndroidNetworkTraffic( signal: AbortSignal, ): Promise { const recent = await host.appLogs.readRecent(input.sessionId, input.maxScanLines); - let dump = readRecentNetworkTrafficFromText(recent.text, { + let scan = readRecentNetworkTrafficFromText(recent.text, { ...input, path: recent.path, exists: recent.exists, @@ -33,14 +33,14 @@ export async function dumpAndroidNetworkTraffic( assertAndroidLogPackageSafe(input.appBundleId); const recovered = await recoverPackageTraffic(host, device, input.appBundleId, signal); if (recovered) { - const recoveryDump = readRecentNetworkTrafficFromText(recovered.text, { + const recoveryScan = readRecentNetworkTrafficFromText(recovered.text, { ...input, path: `${recent.path} (adb logcat recovery)`, exists: true, backend: 'android', }); - if (recoveryDump.entries.length > 0) { - dump = mergeNetworkDumps(recoveryDump, dump, input.maxEntries); + if (recoveryScan.dump.entries.length > 0) { + scan = mergeNetworkScans(recoveryScan, scan, input.maxEntries); notes.push( context.reason === 'stale-active' ? `Session app log stream was still bound to prior Android PID ${context.trackedPid}. Recovered recent Android HTTP entries from adb logcat for PID set ${recovered.pids.join(', ')}.` @@ -58,13 +58,13 @@ export async function dumpAndroidNetworkTraffic( 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', ); } - if (dump.entries.length === 0) { + if (scan.dump.entries.length === 0) { notes.push('No HTTP(s) entries were found in recent session app logs.'); } return Object.freeze({ source: 'app-log', backend: 'android', - dump, + dump: scan.dump, notes: Object.freeze(notes), }); } diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index 0523dd04fd..2a88f22125 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -210,3 +210,119 @@ function unusedAppLogHost(): Omit< 'appleTools' | 'commands' | 'appLogs' | 'networkTransports' >; } + +// Real iOS simulator lines: `/init` reused the connection `/v4/messages/en_US` +// opened, and CFNetwork logged no URL for it. +const CONNECTION_START = + '2026-09-09 18:22:27.805 Df app[1:2] [com.apple.network:connection] [C9 EA66F890 Hostname#c6f77afc:3040 tcp, url: http://localhost:3040/v4/messages/en_US, definite] start'; +const REUSED_SUMMARY = + '2026-09-09 18:22:28.167 Df app[1:2] [com.apple.CFNetwork:Summary] Task <2FAEF670>.<2> summary for task success {transaction_duration_ms=1, response_status=200, connection=9, reused=1, request_bytes=236, response_bytes=624}'; + +test('a keep-alive request reported against its origin does not silence lifecycle guidance', async () => { + const result = await dumpAppleNetworkTraffic( + host({ + text: `${[CONNECTION_START, REUSED_SUMMARY].join('\n')}\n`, + runSimctl: vi.fn(), + }), + simulator, + input({ appLogSnapshot: { state: 'ended', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.notes).toEqual([ + expect.stringContaining('Session app log stream is inactive'), + expect.stringContaining('reused a keep-alive connection'), + ]); + expect(result.notes[1]).toContain('1 listed against the origin'); +}); + +test('a keep-alive request whose connection predates the window keeps the dump from reading empty', async () => { + const result = await dumpAppleNetworkTraffic( + host({ + text: `${REUSED_SUMMARY}\n`, + runSimctl: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 1 })), + }), + simulator, + input({ appLogSnapshot: { state: 'active', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.entries).toEqual([]); + expect(result.dump.unnamedRequests).toBe(1); + expect(result.notes).toEqual([ + expect.stringContaining('1 opened before this scan window'), + expect.stringContaining('No HTTP(s) entries were found'), + ]); + expect(result.notes[0]).toContain('does not prove it was not called'); +}); + +test('simulator recovery keeps traffic it saw but could not name', async () => { + const runSimctl = vi.fn(async () => ({ + stdout: ['Timestamp Ty Process[PID:TID]', REUSED_SUMMARY].join('\n'), + stderr: '', + exitCode: 0, + })); + const result = await dumpAppleNetworkTraffic( + host({ text: '', runSimctl }), + simulator, + input({ appLogSnapshot: { state: 'active', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.entries).toEqual([]); + expect(result.dump.unnamedRequests).toBe(1); + expect(result.notes).toEqual([ + expect.stringContaining('1 opened before this scan window'), + expect.stringContaining('No HTTP(s) entries were found'), + ]); + expect(result.notes.join(' ')).not.toContain('none looked like HTTP traffic'); +}); + +test('recovery-only traffic that cannot be named is still reported, not called empty', async () => { + const runSimctl = vi.fn(async () => ({ + stdout: ['Timestamp Ty Process[PID:TID]', REUSED_SUMMARY].join('\n'), + stderr: '', + exitCode: 0, + })); + const result = await dumpAppleNetworkTraffic( + host({ text: '', runSimctl }), + simulator, + input({ appLogSnapshot: { state: 'active', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.unnamedRequests).toBe(1); + expect(result.notes).toEqual([ + expect.stringContaining('1 opened before this scan window'), + expect.stringContaining('No HTTP(s) entries were found'), + ]); + expect(result.notes.join(' ')).not.toContain('none looked like HTTP traffic'); +}); + +test('a response bounded to one entry still reports every unnamed request, without their ids', async () => { + // Five reused tasks, none resolvable: far more than the requested entry limit. + const summaries = Array.from({ length: 5 }, (_, index) => + REUSED_SUMMARY.replace('Task <2FAEF670>.<2>', `Task <2FAEF670>.<${index + 10}>`), + ); + const result = await dumpAppleNetworkTraffic( + host({ + text: `${summaries.join('\n')}\n`, + runSimctl: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 1 })), + }), + simulator, + input({ maxEntries: 1, appLogSnapshot: { state: 'active', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.entries).toEqual([]); + expect(result.dump.unnamedRequests).toBe(5); + // The identities are a reconciliation detail and must not reach the response, + // where their number is bounded by the scan window rather than by maxEntries. + expect(result.dump).not.toHaveProperty('unnamedRequestIds'); + expect(result.notes[0]).toContain('5 requests reused a keep-alive connection'); +}); diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index 25541c3240..a1f3b0a2a9 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -1,7 +1,11 @@ import type { NetworkDump } from '@agent-device/contracts/network-traffic'; import type { NetworkDumpInput, NetworkDumpResult } from '@agent-device/contracts/network-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; -import { mergeNetworkDumps, readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; +import { + mergeNetworkScans, + readRecentNetworkTrafficFromText, + type NetworkScan, +} from '@agent-device/capture-kit'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { backendForAppleDevice } from '../logs/backend.ts'; @@ -13,7 +17,7 @@ export async function dumpAppleNetworkTraffic( ): Promise { const backend = backendForAppleDevice(device); const recent = await host.appLogs.readRecent(input.sessionId, input.maxScanLines); - let dump = readRecentNetworkTrafficFromText(recent.text, { + let scan = readRecentNetworkTrafficFromText(recent.text, { ...input, path: recent.path, exists: recent.exists, @@ -21,24 +25,47 @@ export async function dumpAppleNetworkTraffic( backend, }); const notes: string[] = []; - if (canRecoverSimulator(device, input, dump)) { + if (canRecoverSimulator(device, input, scan.dump)) { const recovery = await recoverSimulatorTraffic(host, device, input, recent.path, signal); - if (recovery) { - if (recovery.dump.entries.length > 0) { - dump = mergeNetworkDumps(recovery.dump, dump, input.maxEntries); - notes.push( - `Recovered ${recovery.dump.entries.length} iOS simulator HTTP entr${recovery.dump.entries.length === 1 ? 'y' : 'ies'} from simctl log show (${recovery.lineCount} app log lines scanned).`, - ); - } else if (recovery.lineCount > 0) { - notes.push( - `Recovered ${recovery.lineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, - ); - } - } + if (recovery) scan = mergeRecoveredTraffic(notes, scan, recovery, input.maxEntries); } appendLifecycleNote(notes, device, input); - if (dump.entries.length === 0) notes.push(noEntriesNote(device)); - return Object.freeze({ source: 'app-log', backend, dump, notes: Object.freeze(notes) }); + appendUnnamedRequestNote(notes, scan.dump); + if (scan.dump.entries.length === 0) notes.push(noEntriesNote(device)); + return Object.freeze({ + source: 'app-log', + backend, + dump: scan.dump, + notes: Object.freeze(notes), + }); +} + +/** + * Traffic the recovery pass saw but could not name is still traffic, so it is + * merged for its count alone; only a pass that found nothing at all reports the + * window as non-network. + */ +function mergeRecoveredTraffic( + notes: string[], + scan: NetworkScan, + recovery: { scan: NetworkScan; lineCount: number }, + maxEntries: number, +): NetworkScan { + const recovered = recovery.scan.dump.entries.length; + if (recovered === 0 && (recovery.scan.dump.unnamedRequests ?? 0) === 0) { + if (recovery.lineCount > 0) { + notes.push( + `Recovered ${recovery.lineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, + ); + } + return scan; + } + if (recovered > 0) { + notes.push( + `Recovered ${recovered} iOS simulator HTTP entr${recovered === 1 ? 'y' : 'ies'} from simctl log show (${recovery.lineCount} app log lines scanned).`, + ); + } + return mergeNetworkScans(recovery.scan, scan, maxEntries); } function canRecoverSimulator( @@ -60,7 +87,7 @@ async function recoverSimulatorTraffic( input: NetworkDumpInput, appLogPath: string, signal: AbortSignal, -): Promise<{ dump: NetworkDump; lineCount: number } | undefined> { +): Promise<{ scan: NetworkScan; lineCount: number } | undefined> { const args = [ ...(device.simulatorSetPath ? ['--set', device.simulatorSetPath] : []), 'spawn', @@ -93,7 +120,7 @@ async function recoverSimulatorTraffic( ); if (lines.length === 0) return undefined; return { - dump: readRecentNetworkTrafficFromText(`${lines.join('\n')}\n`, { + scan: readRecentNetworkTrafficFromText(`${lines.join('\n')}\n`, { ...input, path: `${appLogPath} (simctl log show recovery)`, exists: true, @@ -113,6 +140,34 @@ function buildPredicate(appBundleId: string): string { ].join(' OR '); } +/** + * CFNetwork logs a request URL only when a connection is opened, so a request + * that reused a keep-alive connection is reported against its connection's + * origin with no path. Saying so keeps "this endpoint was never called" from + * being read off a dump that could not name every request it observed. + */ +function appendUnnamedRequestNote(notes: string[], dump: NetworkDump): void { + const againstOrigin = dump.entries.filter((entry) => entry.pathUnavailable).length; + const unresolved = dump.unnamedRequests ?? 0; + const observed = againstOrigin + unresolved; + if (observed === 0) return; + const parts = [ + `${observed} request${observed === 1 ? '' : 's'} reused a keep-alive connection, so CFNetwork logged no request URL.`, + ]; + if (againstOrigin > 0) { + parts.push( + `${againstOrigin} listed against the origin the connection was opened for, without a path.`, + ); + } + if (unresolved > 0) { + parts.push( + `${unresolved} opened before this scan window and are missing from the entries entirely; scan more lines, or run logs clear --restart before the repro.`, + ); + } + parts.push('Absence of an endpoint in this dump does not prove it was not called.'); + notes.push(parts.join(' ')); +} + function appendLifecycleNote(notes: string[], device: DeviceInfo, input: NetworkDumpInput): void { if (!input.appLogSnapshot) { notes.push( diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index 5b450e85d3..a717238cab 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -494,3 +494,43 @@ test('closes every Limrun gesture and scroll cell without a live session', async }); } }); + +test('an iOS limrun dump bounded to one entry reports unnamed traffic without its identities', async () => { + // Five keep-alive requests CFNetwork logged no URL for, and no connection + // line to resolve them against: many more unnamed tasks than maxEntries. + const summaries = Array.from( + { length: 5 }, + (_, index) => + `2026-09-09 18:22:28.167 Df app[1:2] [com.apple.CFNetwork:Summary] Task <2FAEF670>.<${index + 10}> summary for task success {transaction_duration_ms=1, response_status=200, connection=9, reused=1}`, + ); + const base = unusedHost(); + const owner = createLimrunPlatformRuntimeOwner( + limrunOwnerOptions({ + host: { + ...base, + appLogs: { + ...base.appLogs, + readRecent: async () => ({ + path: '/sessions/session/app.log', + exists: true, + text: `${summaries.join('\n')}\n`, + skippedLines: 0, + }), + }, + }, + }), + ); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + + const result = await binding.operations.networkDump?.({ + sessionId: 'session', + maxEntries: 1, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 4000, + }); + + if (result?.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.unnamedRequests).toBe(5); + expect(result.dump).not.toHaveProperty('unnamedRequestIds'); +}); diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index e3863fd286..8c0c915b2f 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -252,7 +252,7 @@ function bindLimrunAppLogs( networkDump: async (input) => { const recent = await options.host.appLogs.readRecent(input.sessionId, input.maxScanLines); const backend = backendForDevice(device); - const dump = readRecentNetworkTrafficFromText(recent.text, { + const { dump } = readRecentNetworkTrafficFromText(recent.text, { ...input, path: recent.path, exists: recent.exists, diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 81638b314e..51fd9f90d8 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -612,3 +612,53 @@ test('a reachable provider with no overrides still admits its declared operation expect(facts.operations[key].available).toBe(true); } }); + +test('an Apple WebDriver dump bounded to one entry reports unnamed traffic without its identities', async () => { + // Five keep-alive requests CFNetwork logged no URL for, and no connection + // line to resolve them against: many more unnamed tasks than maxEntries. + const summaries = Array.from( + { length: 5 }, + (_, index) => + `2026-09-09 18:22:28.167 Df app[1:2] [com.apple.CFNetwork:Summary] Task <2FAEF670>.<${index + 10}> summary for task success {transaction_duration_ms=1, response_status=200, connection=9, reused=1}`, + ); + const base = host(vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 }))); + const owner = createWebDriverPlatformRuntimeOwner({ + host: { + ...base, + appLogs: { + ...base.appLogs, + readRecent: async () => ({ + path: '/sessions/one/app.log', + exists: true, + text: `${summaries.join('\n')}\n`, + skippedLines: 0, + }), + }, + }, + owner: providerRuntimeOwner('browserstack', 'apple'), + ownsDevice: () => true, + capabilities: capabilities(), + }); + const binding = await owner.bind({ + device: { ...device, platform: 'apple', appleOs: 'ios', id: 'browserstack:lease-ios' }, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + const result = await binding.operations.networkDump?.({ + sessionId: 'one', + maxEntries: 1, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 4000, + }); + + if (result?.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.backend).toBe('ios-device'); + expect(result.dump.unnamedRequests).toBe(5); + expect(result.dump).not.toHaveProperty('unnamedRequestIds'); +}); diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index c011a140d2..664b53fe81 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -424,7 +424,7 @@ function bindWebDriverPlatformRuntime( ...webDriverInteractionOperations(options, device, signal, facts), networkDump: async (input) => { const recent = await options.host.appLogs.readRecent(input.sessionId, input.maxScanLines); - const dump = readRecentNetworkTrafficFromText(recent.text, { + const { dump } = readRecentNetworkTrafficFromText(recent.text, { ...input, path: recent.path, exists: recent.exists, diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index b851079099..6c40de7293 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -309,7 +309,9 @@ function formatNetworkEntry(entry: NetworkCliEntry): string[] { const status = entry.status !== undefined ? ` status=${entry.status}` : ''; const timestamp = entry.timestamp ? `${entry.timestamp} ` : ''; const durationMs = entry.durationMs !== undefined ? ` durationMs=${entry.durationMs}` : ''; - const lines = [`${timestamp}${method} ${url}${status}${durationMs}`]; + const path = + 'pathUnavailable' in entry && entry.pathUnavailable ? ' (request path not logged)' : ''; + const lines = [`${timestamp}${method} ${url}${path}${status}${durationMs}`]; if (entry.headers) { appendNetworkEntryBody(lines, 'headers', entry.headers); } else { diff --git a/src/platform-runtime-network-host.test.ts b/src/platform-runtime-network-host.test.ts index 5d6214b7a5..7de96b09bc 100644 --- a/src/platform-runtime-network-host.test.ts +++ b/src/platform-runtime-network-host.test.ts @@ -54,7 +54,7 @@ test('preserves absolute source line numbers after selecting a bounded suffix', fs.writeFileSync(pathname, `${text}\n`); const recent = readRecentAppLogLines(pathname, 4000); - const dump = readRecentNetworkTrafficFromText(recent.text, { + const { dump } = readRecentNetworkTrafficFromText(recent.text, { path: recent.path, exists: recent.exists, backend: 'android', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index e6cbb2a920..8bed29d648 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -991,6 +991,7 @@ agent-device network dump 25 --include headers --platform web # Browser requests - iOS simulator log capture now streams from inside the simulator with `simctl spawn log ...`, and `network dump` can recover recent simulator log history with `simctl log show` when the live app-log window is sparse. - iOS log capture still relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime. - On iOS, `network dump` can return zero HTTP entries for real app activity when the app does not emit request metadata into Unified Logging. The response notes now distinguish between an empty repro window and a non-network app log window. +- On iOS, CFNetwork logs a request URL only on the line that opens a connection, so a request that reused a keep-alive connection has no URL anywhere in the log. Those requests are reported against the origin their connection was opened for, with `pathUnavailable: true`, their status, and their timing; ones whose connection was opened before the scanned window are counted in `unnamedRequests` instead, since they cannot be named at all. Treat a missing endpoint in an iOS dump as unproven rather than as evidence it was not called. - Retention knobs: set `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES` to override rotation limits. - Optional write-time redaction patterns: set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list.