fix: bound per-navigation network request retention (#2435)

Add an explicit per-navigation request limit to `NetworkCollector` and
evict the oldest entries as new requests arrive, using a production
default of 1,000 retained requests while preserving request order,
stable IDs for retained entries, and the existing three-navigation
policy. `NetworkCollector` limits preserved history to three navigation
buckets, but each bucket—especially the current navigation—can retain an
unlimited number of Puppeteer `HTTPRequest` objects.

Emit more than 1,000 requests on one page without navigating and verify
that only the newest 1,000 remain, in arrival order, with retained
stable IDs still resolving; Cross several main-frame navigations,
including redirect navigation requests and subframe events, and verify
that each of the three retained navigation buckets is independently
bounded without losing the current navigation request.

Refs #2431

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Alex Rudenko <alexrudenko@chromium.org>
This commit is contained in:
Matt Van Horn
2026-07-31 04:03:48 -07:00
committed by GitHub
parent 6f60eabf59
commit b08c73c8d8
6 changed files with 137 additions and 14 deletions
+1 -1
View File
@@ -333,7 +333,7 @@
### `list_network_requests`
**Description:** List all requests for the currently selected page since the last navigation.
**Description:** Lists the most recent requests for the currently selected page since the last navigation.
**Parameters:**
+14 -10
View File
@@ -848,15 +848,19 @@ export class McpPage implements ContextPage {
*/
async setUpNetworkCollectorForTesting() {
this.networkCollector.dispose();
this.networkCollector = new NetworkCollector(this.pptrPage, collect => {
return {
request: req => {
if (req.url().includes('favicon.ico')) {
return;
}
collect(req);
},
} as ListenerMap;
});
this.networkCollector = new NetworkCollector(
this.pptrPage,
undefined,
collect => {
return {
request: req => {
if (req.url().includes('favicon.ico')) {
return;
}
collect(req);
},
} as ListenerMap;
},
);
}
}
+14 -1
View File
@@ -60,6 +60,7 @@ export class PageCollector<T> {
constructor(
page: Page,
listeners: (collector: (item: T) => void) => ListenerMap<PageEvents>,
maxResourcesPerNavigation?: number,
) {
this.pptrPage = page;
@@ -69,6 +70,15 @@ export class PageCollector<T> {
const withId = value as WithSymbolId<T>;
withId[stableIdSymbol] = idGenerator();
this.storage[0].push(withId);
if (
maxResourcesPerNavigation !== undefined &&
this.storage[0].length > maxResourcesPerNavigation
) {
this.storage[0].splice(
0,
this.storage[0].length - maxResourcesPerNavigation,
);
}
});
listenerMap['framenavigated'] = (frame: Frame) => {
@@ -283,8 +293,11 @@ class PageEventSubscriber {
}
export class NetworkCollector extends PageCollector<HTTPRequest> {
static readonly MAX_REQUESTS_PER_NAVIGATION = 1_000;
constructor(
page: Page,
maxRequestsPerNavigation = NetworkCollector.MAX_REQUESTS_PER_NAVIGATION,
listeners: (
collector: (item: HTTPRequest) => void,
) => ListenerMap<PageEvents> = collect => {
@@ -295,7 +308,7 @@ export class NetworkCollector extends PageCollector<HTTPRequest> {
} as ListenerMap;
},
) {
super(page, listeners);
super(page, listeners, maxRequestsPerNavigation);
}
override splitAfterNavigation() {
const requests = this.storage[0];
+1 -1
View File
@@ -775,7 +775,7 @@ export const commands: Commands = {
},
list_network_requests: {
description:
'List all requests for the currently selected page since the last navigation.',
'Lists the most recent requests for the currently selected page since the last navigation.',
category: 'Network',
args: {
pageSize: {
+1 -1
View File
@@ -34,7 +34,7 @@ const FILTERABLE_RESOURCE_TYPES: readonly [ResourceType, ...ResourceType[]] = [
export const listNetworkRequests = definePageTool({
name: 'list_network_requests',
description: `List all requests for the currently selected page since the last navigation.`,
description: `Lists the most recent requests for the currently selected page since the last navigation.`,
annotations: {
category: ToolCategory.NETWORK,
readOnlyHint: true,
+106
View File
@@ -126,6 +126,35 @@ describe('PageCollector', () => {
});
describe('NetworkCollector', () => {
it('retains only the newest requests without navigation', async () => {
const browser = getMockBrowser();
const page = (await browser.pages())[0];
const collector = new NetworkCollector(page);
const requests = Array.from(
{
length: NetworkCollector.MAX_REQUESTS_PER_NAVIGATION + 1,
},
(_, index) =>
getMockRequest({url: `http://example.com/request-${index + 1}`}),
);
for (const request of requests) {
page.emit('request', request);
}
const retainedRequests = collector.getData();
assert.equal(
retainedRequests.length,
NetworkCollector.MAX_REQUESTS_PER_NAVIGATION,
);
assert.deepEqual(retainedRequests, requests.slice(1));
assert.equal(collector.getIdForResource(retainedRequests[0]), 2);
assert.equal(collector.getById(2), retainedRequests[0]);
assert.throws(() => collector.getById(1), {
message: 'Request not found for selected page',
});
});
it('correctly picks up navigation requests to latest navigation', async () => {
const browser = getMockBrowser();
const page = (await browser.pages())[0];
@@ -244,6 +273,83 @@ describe('NetworkCollector', () => {
// Each navigation has 1 request, so total should be 3
assert.equal(collector.getData(true).length, 3);
});
it('bounds retained navigation buckets with redirects and subframes', async () => {
class TestNetworkCollector extends NetworkCollector {
get navigationSizes(): number[] {
return this.storage.map(requests => requests.length);
}
}
const browser = getMockBrowser();
const page = (await browser.pages())[0];
const mainFrame = page.mainFrame();
const collector = new TestNetworkCollector(page, 3);
const firstNavigation = getMockRequest({
url: 'http://example.com/first',
navigationRequest: true,
frame: mainFrame,
});
const firstResource = getMockRequest({
url: 'http://example.com/first-resource',
});
const subframeResource = getMockRequest({
url: 'http://example.com/subframe-resource',
});
page.emit('request', firstNavigation);
page.emit('framenavigated', mainFrame);
page.emit('request', firstResource);
page.emit('framenavigated', {} as Frame);
page.emit('request', subframeResource);
const redirectNavigation = getMockRequest({
url: 'http://example.com/redirect',
navigationRequest: true,
frame: mainFrame,
});
const redirectedNavigation = getMockRequest({
url: 'http://example.com/redirected',
navigationRequest: true,
frame: mainFrame,
});
const redirectedResource = getMockRequest({
url: 'http://example.com/redirected-resource',
});
page.emit('request', redirectNavigation);
page.emit('request', redirectedNavigation);
page.emit('framenavigated', mainFrame);
page.emit('request', redirectedResource);
const finalNavigation = getMockRequest({
url: 'http://example.com/final',
navigationRequest: true,
frame: mainFrame,
});
const finalResource1 = getMockRequest({
url: 'http://example.com/final-resource-1',
});
const finalResource2 = getMockRequest({
url: 'http://example.com/final-resource-2',
});
page.emit('request', finalNavigation);
page.emit('framenavigated', mainFrame);
page.emit('request', finalResource1);
page.emit('request', finalResource2);
assert.deepEqual(collector.getData(true), [
subframeResource,
redirectNavigation,
redirectedNavigation,
redirectedResource,
finalNavigation,
finalResource1,
finalResource2,
]);
assert.equal(collector.getData().length, 3);
assert.equal(collector.getData()[0], finalNavigation);
assert.deepEqual(collector.navigationSizes, [3, 2, 2]);
});
});
describe('ConsoleCollector', () => {