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:
@@ -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
@@ -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
@@ -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];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user