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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <value>,` 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 <appId>` target as an explicit Maestro `launchApp.appId`.
- Added: `replay export` converts recorded `home` actions to Maestro `pressKey: Home`, allowing
Expand Down
6 changes: 5 additions & 1 deletion packages/capture-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 1 addition & 1 deletion packages/capture-kit/src/network-traffic-android.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
225 changes: 218 additions & 7 deletions packages/capture-kit/src/network-traffic.test.ts
Original file line number Diff line number Diff line change
@@ -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"}}',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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,
});
Expand All @@ -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);
});
Loading
Loading